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:
9 files changed
+288
-3
No files matched your search
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 账号的额度窗口一重置,就替它发一条极小的请求,让下一个窗口立即开始计时",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user