tray: a subscription's use can sit beside the icon in the menu bar — Settings → Usage in the menu bar picks one of the Usage page's cards, and its windows' use shows there ("42% · 18%", the shortest window first; a key's balance as it reads), with each window and when it starts again in the tooltip, brought up to date every few minutes from the page's own caches, so the vendors are asked no more often. Windows' tray has no room for text and shows the tooltip only. Asked for on Discord

This commit is contained in:
yetone committed 2026-09-27 19:10:27 +08:00
1 parent 86a0b90580
commit 9cbcb681dd
9 files changed
+288 -3

No files matched your search

+3
View File
@@ -323,6 +323,9 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
if (in.Dock != cur.Dock || in.DockWindow != cur.DockWindow) && onDock != nil {
onDock(in)
}
if in.TrayUsage != cur.TrayUsage && onTrayUsage != nil {
onTrayUsage()
}
writeJSON(rw, settingsState())
})
// the Agents page's order and what it folds away, in magpie's settings
+1
View File
@@ -278,6 +278,7 @@ func Run(version string, showMain bool, link string) error {
}
h.tray.SetMenu(menu)
h.tray.AttachWindow(h.panel).WindowOffset(6)
h.watchTrayUsage()
// the quick panel by the icon, or the main window if the user would
// rather (Settings → Tray icon)
h.tray.OnClick(func() {
+6
View File
@@ -840,6 +840,12 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
.acc .proto:hover, .acc .proto.open { color: var(--fg); background: var(--pill-hover); }
.acc .n, .acc .rename { flex: 0 1 auto; }
.acc .plan { flex: none; }
/* the Settings row's picker: the key row's badge, always shown */
.row.pref .proto.pick { flex: none; margin-left: auto; display: inline-flex; align-items: center; gap: 3px; height: 24px; padding: 0 6px 0 9px; font: inherit; font-size: 12px; color: var(--fg-2); border: 0; border-radius: 7px; background: var(--pill); white-space: nowrap; cursor: default; transition: background .12s, color .12s; }
.row.pref .proto.pick svg { opacity: .7; transition: transform .15s var(--ease); }
.row.pref .proto.pick.open svg { transform: rotate(180deg); }
.row.pref .proto.pick:hover, .row.pref .proto.pick.open { color: var(--fg); background: var(--pill-hover); }
.row.pref .proto.pick.busy { opacity: .6; }
.pop.proto-menu { width: 262px; max-height: none; padding: 4px; border: 1px solid var(--line); display: grid; gap: 1px; }
.proto-menu .pm-head { padding: 6px 8px 5px; font-size: 11px; color: var(--muted); }
.proto-menu .pm-item { display: flex; align-items: flex-start; gap: 7px; width: 100%; padding: 6px 8px 7px; border: 0; border-radius: 7px; background: none; color: var(--fg); font: inherit; text-align: left; cursor: default; outline: none; }
+39 -3
View File
@@ -3513,11 +3513,11 @@ function closeProtoMenu() {
removeEventListener("resize", closeProtoMenu);
protoMenu = null;
}
function openProtoMenu(anchor, opts, value, choose) {
function openProtoMenu(anchor, opts, value, choose, head = "Protocol this key speaks") {
closeProtoMenu();
const box = el("div", "pop proto-menu");
box.setAttribute("role", "menu");
box.append(el("div", "pm-head", t("Protocol this key speaks")));
box.append(el("div", "pm-head", t(head)));
const items = opts.map((o) => {
const b = el("button", "pm-item" + (o.v === value ? " on" : ""));
b.type = "button";
@@ -4251,7 +4251,8 @@ const DISCORD_SVG = '<svg viewBox="0 0 24 24" width="13" height="13" fill="curre
function renderSettings() {
const s = prefs;
const keep = { theme: s.theme, lang: s.lang, tray: s.tray, dock: !!s.dock, dockWindow: !!s.dockWindow, proxy: s.proxy || "",
redact: !!s.redact, redactPersonal: !!s.redactPersonal, redactWords: s.redactWords || [], codexWarmup: s.codexWarmup || "" };
redact: !!s.redact, redactPersonal: !!s.redactPersonal, redactWords: s.redactWords || [], codexWarmup: s.codexWarmup || "",
trayUsage: s.trayUsage || "" };
$("#themeSegs").replaceChildren(segs(THEMES.map(([id, name]) => [id, t(name)]), s.theme, (theme) => savePrefs({ ...keep, theme })));
$("#langSegs").replaceChildren(segs(LOCALES.map(([id, name]) => [id, t(name)]), s.lang, (lang) => savePrefs({ ...keep, lang })));
$("#traySegs").replaceChildren(segs(TRAYS.map(([id, name]) => [id, t(name)]), s.tray || "panel", (tray) => savePrefs({ ...keep, tray })));
@@ -4268,6 +4269,7 @@ function renderSettings() {
s.codexWarmup || "off", (v) => savePrefs({ ...keep, codexWarmup: v === "off" ? "" : v })));
$("#warmSub").textContent = t("When a ChatGPT account's window resets, send it one tiny request so the next one starts counting at once")
+ (s.codexWarmed ? " · " + t("last started {when}", { when: syncWhen(s.codexWarmed) }) : "");
renderTrayUsage(s, keep);
renderProxy(s, keep);
renderRedact(s, keep);
renderLAN(s);
@@ -4511,6 +4513,40 @@ function importForm() {
return ed;
}
// trayCardID names a Usage page card as settings.TrayUsage does: its
// provider, and the account when there is one.
const trayCardID = (q) => q.user ? q.provider + "|" + q.user : q.provider;
// renderTrayUsage: the subscription or plan whose windows show beside the
// tray icon. The cards are the Usage page's, asked for when the menu opens.
function renderTrayUsage(s, keep) {
$("#trayUsageRow").hidden = web;
if (web) return;
const mac = document.body.classList.contains("mac");
$("#trayUsageSub").textContent = mac ? t("Show a subscription's use beside magpie's icon in the menu bar, refreshed every few minutes")
: t("Show a subscription's use when pointing at magpie's tray icon, refreshed every few minutes");
const id = s.trayUsage || "";
const pill = el("button", "proto pick" + (id ? " set" : ""));
pill.type = "button";
const paint = () => {
const card = (quotas || []).find((q) => trayCardID(q) === id);
pill.replaceChildren(el("span", "", !id ? t("Off") : card ? card.name : id.split("|")[0]), svg(CHEV, 11, 1.6));
};
paint();
if (id && !quotas) loadQuotas().then(paint);
pill.onclick = async (e) => {
e.stopPropagation();
if (pill.classList.contains("open")) return closeProtoMenu();
if (!quotas) { pill.classList.add("busy"); await loadQuotas(); pill.classList.remove("busy"); paint(); }
const cards = (quotas || []).filter((q) => !q.error && (q.windows?.length || q.balance));
const opts = [{ v: "", name: "Off", note: "" },
...cards.map((q) => ({ v: trayCardID(q), name: q.name, note: [q.plan, q.user].filter(Boolean).join(" · ") }))];
if (!cards.length) opts.push({ v: "\x00", name: "No subscriptions yet", note: "Sign in to one, or add a plan's key, and it shows on the Usage page" });
openProtoMenu(pill, opts, id, (v) => { if (v !== id && v !== "\x00") savePrefs({ ...keep, trayUsage: v }); }, "Shown beside the icon");
};
$("#trayUsagePick").replaceChildren(pill);
}
// renderProxy: magpie's own requests to vendors follow the system proxy on
// their own; this row says which one, and lets it be turned off or set.
let proxyCustom = false; // Custom picked, nothing typed yet
+6
View File
@@ -87,6 +87,12 @@ const I18N = {
"API keys, private keys, tokens and passwords go to vendors as placeholders, and come back as they were": "API Key、私钥、Token 和密码以占位符发给模型厂商,回复里再还原",
"Mask personal data": "脱敏个人信息",
"Codex warm-up": "Codex 预热",
"Usage in the menu bar": "菜单栏显示用量",
"Show a subscription's use beside magpie's icon in the menu bar, refreshed every few minutes": "在菜单栏 magpie 图标旁显示一个订阅的用量,每隔几分钟刷新",
"Show a subscription's use when pointing at magpie's tray icon, refreshed every few minutes": "鼠标指向托盘里的 magpie 图标时显示一个订阅的用量,每隔几分钟刷新",
"Shown beside the icon": "显示在图标旁",
"No subscriptions yet": "还没有订阅",
"Sign in to one, or add a plan's key, and it shows on the Usage page": "登录一个订阅或添加套餐的 Key 后,会出现在用量页",
"Weekly": "每周",
"Weekly and 5-hour": "每周和 5 小时",
"When a ChatGPT account's window resets, send it one tiny request so the next one starts counting at once": "ChatGPT 账号的额度窗口一重置,就替它发一条极小的请求,让下一个窗口立即开始计时",
+4
View File
@@ -133,6 +133,10 @@
<div class="who"><div class="name" data-t>Tray icon</div><div class="sub" data-t>What clicking magpie’s icon in the menu bar or tray opens</div></div>
<div id="traySegs"></div>
</div>
<div class="row pref" id="trayUsageRow">
<div class="who"><div class="name" data-t>Usage in the menu bar</div><div class="sub" id="trayUsageSub"></div></div>
<div id="trayUsagePick"></div>
</div>
<div class="row pref">
<div class="who"><div class="name" data-t>Open at login</div><div class="sub" data-t>Start magpie in the tray when you log in, so agents find the gateway from the start</div></div>
<div id="loginSegs"></div>
+166
View File
@@ -0,0 +1,166 @@
package gui
import (
"cmp"
"context"
"fmt"
"math"
"slices"
"strings"
"time"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/settings"
)
// Settings → Usage in the menu bar: one subscription's or plan's windows
// beside the tray icon ("42% · 18%"), for keeping an eye on them without
// opening magpie. The Mac's menu bar shows the text; Windows' tray has no
// room for any, and gets it in the icon's tooltip only.
// trayUsageEvery is how often the text is brought up to date. The vendors
// are asked no more than the Usage page asks them: their answers are
// cached for a minute, and this reads the cache.
const trayUsageEvery = 3 * time.Minute
// trayCardID names a card for settings.TrayUsage: its provider, and the
// account when there is one, as two of one vendor can be signed in.
func trayCardID(q provider.SubscriptionQuota) string {
if q.User == "" {
return q.Provider
}
return q.Provider + "|" + q.User
}
// trayWindows are the windows a card's text shows: those that stop the
// account (not on-demand spending, nor one that counts a single model's
// use), the shortest first where the vendor says how long they run.
func trayWindows(q provider.SubscriptionQuota) []provider.QuotaWindow {
var ws []provider.QuotaWindow
for _, w := range q.Windows {
if !w.Aside && w.Model == "" {
ws = append(ws, w)
}
}
slices.SortStableFunc(ws, func(a, b provider.QuotaWindow) int {
if a.Span == 0 || b.Span == 0 {
return 0
}
return cmp.Compare(a.Span, b.Span)
})
return ws
}
// trayUsageText is the menu bar's text for a card and the tooltip that
// spells it out: each window's use, and when it starts again.
func trayUsageText(q provider.SubscriptionQuota, now time.Time) (label, tip string) {
if q.Error != "" {
return "", q.Name + ": " + q.Error
}
ws := trayWindows(q)
if len(ws) == 0 {
if q.Balance == "" {
return "", ""
}
return q.Balance, q.Name + " · " + q.Balance
}
var short, long []string
for _, w := range ws {
pct := fmt.Sprintf("%d%%", int(math.Round(math.Max(0, math.Min(100, w.Used)))))
short = append(short, pct)
line := w.Name + " " + pct
if w.Display != "" {
line = w.Name + " " + w.Display
}
if at := resetAt(w, now); !at.IsZero() && at.After(now) {
line += " · resets in " + until(at.Sub(now))
}
long = append(long, line)
}
if len(short) > 2 {
short = short[:2]
}
return strings.Join(short, " · "), q.Name + "\n" + strings.Join(long, "\n")
}
func resetAt(w provider.QuotaWindow, now time.Time) time.Time {
if w.ResetsAt != nil {
return *w.ResetsAt
}
if w.ResetSecs > 0 {
return now.Add(time.Duration(w.ResetSecs) * time.Second)
}
return time.Time{}
}
// until says a wait in the largest two units: 3d 4h, 2h 10m, 7m.
func until(d time.Duration) string {
d = d.Round(time.Minute)
days, hours, mins := int(d/(24*time.Hour)), int(d%(24*time.Hour)/time.Hour), int(d%time.Hour/time.Minute)
switch {
case days > 0:
return fmt.Sprintf("%dd %dh", days, hours)
case hours > 0:
return fmt.Sprintf("%dh %dm", hours, mins)
}
return fmt.Sprintf("%dm", max(mins, 1))
}
// trayUsageCard is the card settings.TrayUsage names, among the Usage
// page's (cached as there); false when it is off or gone.
func trayUsageCard(ctx context.Context) (provider.SubscriptionQuota, bool) {
id := settings.Load().TrayUsage
if id == "" {
return provider.SubscriptionQuota{}, false
}
cards := provider.Quotas(ctx)
i := slices.IndexFunc(cards, func(q provider.SubscriptionQuota) bool { return trayCardID(q) == id })
if i < 0 {
return provider.SubscriptionQuota{}, false
}
return cards[i], true
}
// onTrayUsage brings the tray's text up to date at once, when the Settings
// page changes which card it shows; set by the process that has the tray.
var onTrayUsage func()
// watchTrayUsage keeps the tray's text up to date, and plain "magpie" in
// the tooltip while it is off.
func (h *host) watchTrayUsage() {
wake := make(chan struct{}, 1)
onTrayUsage = func() {
select {
case wake <- struct{}{}:
default:
}
}
go func() {
<-h.ready // the tray is made once the app runs
shown := ""
for {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
label, tip := "", "magpie"
if q, ok := trayUsageCard(ctx); ok {
if label, tip = trayUsageText(q, time.Now()); tip == "" {
tip = "magpie"
}
}
cancel()
if label+"\x00"+tip != shown {
shown = label + "\x00" + tip
h.tray.SetLabel(label)
h.tray.SetTooltip(tip)
}
// the first answer can take a while; look again soon after it
next := trayUsageEvery
if label == "" && settings.Load().TrayUsage != "" {
next = 20 * time.Second
}
select {
case <-wake:
case <-time.After(next):
}
}
}()
}
+60
View File
@@ -0,0 +1,60 @@
package gui
import (
"testing"
"time"
"github.com/yetone/magpie/internal/provider"
)
func TestTrayUsageText(t *testing.T) {
now := time.Date(2026, 9, 27, 12, 0, 0, 0, time.UTC)
in := func(d time.Duration) *time.Time { at := now.Add(d); return &at }
// the shortest window first, whatever order the vendor gives them in;
// on-demand spending and a single model's window are left out
q := provider.SubscriptionQuota{Provider: "claude", Name: "Claude", User: "a@b.c", Windows: []provider.QuotaWindow{
{Name: "Weekly", Used: 17.6, ResetsAt: in(3*24*time.Hour + 4*time.Hour), Span: 7 * 24 * time.Hour},
{Name: "5-hour", Used: 42.2, ResetsAt: in(2*time.Hour + 10*time.Minute), Span: 5 * time.Hour},
{Name: "Opus weekly", Used: 90, Model: "opus", Span: 7 * 24 * time.Hour},
{Name: "Extra usage", Used: 5, Aside: true},
}}
label, tip := trayUsageText(q, now)
if label != "42% · 18%" {
t.Errorf("label %q", label)
}
if want := "Claude\n5-hour 42% · resets in 2h 10m\nWeekly 18% · resets in 3d 4h"; tip != want {
t.Errorf("tip %q, want %q", tip, want)
}
if id := trayCardID(q); id != "claude|a@b.c" {
t.Errorf("id %q", id)
}
// no lengths known: the vendor's order, two at most, clamped, its own count
q = provider.SubscriptionQuota{Provider: "zcode", Name: "ZCode", Windows: []provider.QuotaWindow{
{Name: "5 小时", Used: 120, Display: "1.2k / 1k"},
{Name: "Weekly", Used: -3, ResetSecs: 90},
{Name: "Monthly", Used: 1},
}}
label, tip = trayUsageText(q, now)
if label != "100% · 0%" {
t.Errorf("label %q", label)
}
if want := "ZCode\n5 小时 1.2k / 1k\nWeekly 0% · resets in 2m\nMonthly 1%"; tip != want {
t.Errorf("tip %q, want %q", tip, want)
}
if id := trayCardID(q); id != "zcode" {
t.Errorf("id %q", id)
}
// a balance, an error, nothing
if label, _ = trayUsageText(provider.SubscriptionQuota{Name: "DeepSeek", Balance: "¥12.30"}, now); label != "¥12.30" {
t.Errorf("balance label %q", label)
}
if label, tip = trayUsageText(provider.SubscriptionQuota{Name: "Codex", Error: "signed out"}, now); label != "" || tip != "Codex: signed out" {
t.Errorf("error: %q %q", label, tip)
}
if label, tip = trayUsageText(provider.SubscriptionQuota{Name: "Empty"}, now); label != "" || tip != "" {
t.Errorf("empty: %q %q", label, tip)
}
}
+3
View File
@@ -51,6 +51,9 @@ type Settings struct {
// Codex window starts at its first use): "" off, "week" the weekly
// window, "all" the 5-hour one too.
CodexWarmup string `json:"codexWarmup,omitempty"`
// TrayUsage is the subscription or plan whose windows are shown beside
// the tray icon, by its provider and account ("claude|a@b.c"); "" none.
TrayUsage string `json:"trayUsage,omitempty"`
// How the agents are listed, by agent id. AgentOrder comes first, as
// ordered; an agent it doesn't name (one installed since) follows in
// magpie's own order. A hidden agent is folded away at the bottom of the