diff --git a/go.mod b/go.mod
index 7172e466..3b660bb8 100644
--- a/go.mod
+++ b/go.mod
@@ -20,6 +20,7 @@ require (
)
require (
+ git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
diff --git a/go.sum b/go.sum
index b7d70cd3..8e942c10 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,5 @@
+git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
+git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
diff --git a/internal/gui/api.go b/internal/gui/api.go
index 7ec167ea..e5426ac6 100644
--- a/internal/gui/api.go
+++ b/internal/gui/api.go
@@ -187,6 +187,9 @@ type settingsJSON struct {
WorkBuddyCheckins []provider.WorkBuddyCheckin `json:"workbuddyCheckins,omitempty"`
// FX is the dollar-to-yuan rate the cny currency choice shows costs at
FX fxJSON `json:"fx"`
+ // NotifyProblem is why a usage alert set wouldn't be seen: "denied"
+ // (notifications turned off for magpie) or "unavailable"
+ NotifyProblem string `json:"notifyProblem,omitempty"`
}
func settingsState() settingsJSON {
@@ -198,6 +201,9 @@ func settingsState() settingsJSON {
s.TerminalDefault = found.Default
}
s.FX = currentFX()
+ if (s.UsageAlert > 0 || s.BalanceAlert > 0) && notifyProblem != nil {
+ s.NotifyProblem = notifyProblem()
+ }
s.ProxyNow, s.ProxySource = netproxy.Describe()
s.Login = autostart.Enabled()
if s.LAN {
@@ -503,6 +509,12 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
if (!slices.Equal(settings.Load().TrayUsages, cur.TrayUsages) || in.TrayUsageEvery != cur.TrayUsageEvery) && onTrayUsage != nil {
onTrayUsage()
}
+ // an alert turned on or moved is looked at now, the Mac asked for its
+ // leave to notify as it is turned on (#368)
+ if (in.UsageAlert != cur.UsageAlert || in.BalanceAlert != cur.BalanceAlert) &&
+ (in.UsageAlert > 0 || in.BalanceAlert > 0) && onAlerts != nil {
+ onAlerts()
+ }
// the tray menu follows the page's language (#301)
if in.Lang != cur.Lang && onLang != nil {
onLang()
diff --git a/internal/gui/app.go b/internal/gui/app.go
index 432be1d7..e1dee40b 100644
--- a/internal/gui/app.go
+++ b/internal/gui/app.go
@@ -416,6 +416,7 @@ func Run(version string, showMain bool, link string) error {
h.tray.SetMenu(menu)
h.tray.AttachWindow(h.panel).WindowOffset(6)
h.watchTrayUsage()
+ h.watchAlerts()
// the quick panel by the icon, or the main window if the user would
// rather (Settings → Tray icon)
h.tray.OnClick(func() {
diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css
index 6091de90..8a3ab14f 100644
--- a/internal/gui/assets/app.css
+++ b/internal/gui/assets/app.css
@@ -1606,6 +1606,11 @@ input.words.rule-match { flex: 1 1 auto; width: auto; min-width: 0; }
.warm-at input.at { width: auto; height: 27px; padding: 0 8px; border-radius: 7px; border: 1px solid var(--line); background: var(--card); color: var(--fg); font: inherit; font-size: 12.5px; font-variant-numeric: tabular-nums; outline: none; transition: border-color .12s, box-shadow .12s; }
.warm-at input.at:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); }
.warm-at input.at::-webkit-calendar-picker-indicator { opacity: .55; }
+/* a usage alert's share or amount: a short number field, its unit beside it */
+.warm-at input.at.num { width: 64px; text-align: right; }
+.warm-at .unit { color: var(--muted); font-size: 12.5px; margin-left: -4px; }
+.row.pref .sub .warn { color: var(--amber); }
+.row.pref .sub.wraps { white-space: normal; }
/* the field's clock and picker in the page's own palette */
:root[data-theme="dark"] .warm-at input.at { color-scheme: dark; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .warm-at input.at { color-scheme: dark; } }
diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js
index 35e43f8c..bc9b6cef 100644
--- a/internal/gui/assets/app.js
+++ b/internal/gui/assets/app.js
@@ -8961,6 +8961,7 @@ function renderTrayUsage(s, keep) {
$("#quotaLeftSegs").replaceChildren(segs([[false, t("Used")], [true, t("Left")]], !!s.quotaLeft,
(on) => { if (on !== quotaLeft) setQuotaLeft(on); }));
$("#currencySegs").replaceChildren(segs(CURRENCIES.map(([id, name]) => [id, t(name)]), s.currency || "usd", (v) => savePrefs({ ...keep, currency: v })));
+ renderAlerts(s, keep);
// the agents' lists name a model with its provider's after it, or alone
// (#335): set on its own, so the agents are told
$("#plainNamesSegs").replaceChildren(segs([["off", t("Off")], ["on", t("On")]], s.plainNames ? "off" : "on", (v) =>
@@ -9074,6 +9075,48 @@ function renderWarmAt(box, sub, at, onReset, via, save) {
box.append(segs([["off", t("Off")], ["on", t("On")]], at ? "on" : "off", (v) => save(v === "on" ? at || "06:00" : "")));
}
+// renderAlerts draws the usage alerts (#368): a notification when a window
+// that routing counts reaches the share set, or a balance falls to the
+// amount set, each once; Off, or the number in a field beside On.
+function renderAlerts(s, keep) {
+ const problem = s.notifyProblem === "denied" ? t("Notifications are turned off for magpie in the system's settings")
+ : s.notifyProblem === "unavailable" ? t("Notifications can't be shown on this system") : "";
+ const sub = (id, what) => {
+ const box = $(id);
+ box.textContent = what;
+ box.title = what;
+ // the problem said whole, the line let wrap for it
+ box.classList.toggle("wraps", !!problem);
+ if (problem) box.append(" · ", el("span", "warn", problem));
+ };
+ sub("#usageAlertSub", t("A notification when a 5-hour, weekly or monthly window reaches this share used, once each time it runs"));
+ sub("#balanceAlertSub", t("A notification when a balance falls to this amount, in its own currency or credits, once until it is topped up"));
+ const field = (box, value, label, unit, ok, save) => {
+ box.replaceChildren();
+ if (value) {
+ const i = input(String(value), "", "number");
+ i.className = "at num";
+ i.inputMode = "decimal";
+ i.setAttribute("aria-label", label);
+ i.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter") i.blur(); };
+ i.onchange = () => {
+ const n = Number(i.value);
+ if (!ok(n)) { i.value = String(value); return; }
+ if (n !== value) save(n);
+ };
+ box.append(i);
+ if (unit) box.append(el("span", "unit", unit));
+ }
+ return box;
+ };
+ field($("#usageAlertSegs"), s.usageAlert || 0, t("Share used"), "%", (n) => Number.isInteger(n) && n >= 1 && n <= 100,
+ (n) => savePrefs({ ...keep, usageAlert: n }))
+ .append(segs([["off", t("Off")], ["on", t("On")]], s.usageAlert ? "on" : "off", (v) => savePrefs({ ...keep, usageAlert: v === "on" ? s.usageAlert || 80 : 0 })));
+ field($("#balanceAlertSegs"), s.balanceAlert || 0, t("Amount"), "", (n) => Number.isFinite(n) && n > 0,
+ (n) => savePrefs({ ...keep, balanceAlert: n }))
+ .append(segs([["off", t("Off")], ["on", t("On")]], s.balanceAlert ? "on" : "off", (v) => savePrefs({ ...keep, balanceAlert: v === "on" ? s.balanceAlert || 5 : 0 })));
+}
+
// renderImages: the model that describes images to a model that can't see
// them — the one magpie picks, one named, or none, and then such an image
// is turned away.
@@ -9414,7 +9457,8 @@ function prefsKeep(s) {
trayUsages: s.trayUsages || [],
redact: !!s.redact, redactPersonal: !!s.redactPersonal, redactWords: s.redactWords || [], codexWarmup: s.codexWarmup || "",
claudeWarmup: s.claudeWarmup || "", codexWarmAt: s.codexWarmAt || "", claudeWarmAt: s.claudeWarmAt || "", workbuddyCheckin: !!s.workbuddyCheckin, noStats: !!s.noStats,
- trayUsage: s.trayUsage || "", trayUsageEvery: s.trayUsageEvery || 3, vision: s.vision || "", imageGen: s.imageGen || "", currency: s.currency || "usd" };
+ trayUsage: s.trayUsage || "", trayUsageEvery: s.trayUsageEvery || 3, vision: s.vision || "", imageGen: s.imageGen || "", currency: s.currency || "usd",
+ usageAlert: s.usageAlert || 0, balanceAlert: s.balanceAlert || 0 };
}
// savePrefs sends what the page was drawn with (prefsBase) and the choice
diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js
index a7ebdccc..d382170a 100644
--- a/internal/gui/assets/i18n.js
+++ b/internal/gui/assets/i18n.js
@@ -152,6 +152,14 @@ const I18N = {
"Usage shows": "用量显示",
"How much of each window is used, or how much is left, on the Usage page, the panel and the menu bar": "每个窗口显示已用多少或剩余多少,用量页、面板和菜单栏一致",
"Currency": "货币",
+ "Usage alert": "用量提醒",
+ "Low balance alert": "余额提醒",
+ "A notification when a 5-hour, weekly or monthly window reaches this share used, once each time it runs": "5 小时、每周或每月窗口用到这个比例时发一条系统通知,每个周期只提醒一次",
+ "A notification when a balance falls to this amount, in its own currency or credits, once until it is topped up": "余额降到这个数(按它自己的货币或积分)时发一条系统通知,充值回升前只提醒一次",
+ "Notifications are turned off for magpie in the system's settings": "系统设置里关闭了 magpie 的通知",
+ "Notifications can't be shown on this system": "此系统无法显示通知",
+ "Share used": "已用比例",
+ "Amount": "金额",
"Provider in model names": "模型名带提供商",
"Agents’ model lists name each model with its provider or “routing group” after it. Off, the name alone, unless two would read the same": "写给 agent 的模型列表里,每个模型名后面带上提供商或“routing group”。关闭后只用模型名,除非有两个会重名",
"$ USD": "$ 美元",
diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html
index e308bd32..6d0ac9f9 100644
--- a/internal/gui/assets/index.html
+++ b/internal/gui/assets/index.html
@@ -201,6 +201,14 @@
Provider in model names
Agents’ model lists name each model with its provider or “routing group” after it. Off, the name alone, unless two would read the same
diff --git a/internal/gui/quota_alert.go b/internal/gui/quota_alert.go
new file mode 100644
index 00000000..ba07a3d8
--- /dev/null
+++ b/internal/gui/quota_alert.go
@@ -0,0 +1,102 @@
+package gui
+
+import (
+ "fmt"
+ "math"
+ "regexp"
+ "strconv"
+ "time"
+
+ "github.com/yetone/magpie/internal/provider"
+)
+
+// onAlerts is told when the Settings page turns a usage or balance alert
+// on, for the process that notifies to ask the system's leave to and look
+// at once; set by the process that has the tray.
+var onAlerts func()
+
+// notifyProblem says why magpie's notifications can't be shown, "" when
+// they can or it isn't known: "denied" (turned off for magpie in the
+// system's settings) or "unavailable" (a build or desktop without them).
+// Set by the process that has the tray.
+var notifyProblem func() string
+
+var (
+ hoursName = regexp.MustCompile(`^(\d+) hours?$`)
+ daysName = regexp.MustCompile(`^(\d+) days?$`)
+)
+
+// alertWindowZh is a window's name in Chinese, as the page's i18n.js has
+// the common ones; one it doesn't know stays as the vendor named it.
+func alertWindowZh(name string) string {
+ if m := hoursName.FindStringSubmatch(name); m != nil {
+ return m[1] + " 小时"
+ }
+ if m := daysName.FindStringSubmatch(name); m != nil {
+ return m[1] + " 天"
+ }
+ switch name {
+ case "Weekly":
+ return "每周"
+ case "Monthly":
+ return "每月"
+ case "Daily":
+ return "每日"
+ }
+ return name
+}
+
+// alertClockZh is provider.ResetClock in Chinese.
+func alertClockZh(at, now time.Time) string {
+ at, now = at.Local(), now.Local()
+ day := func(t time.Time) time.Time { y, m, d := t.Date(); return time.Date(y, m, d, 0, 0, 0, 0, time.Local) }
+ switch days := int(math.Round(day(at).Sub(day(now)).Hours() / 24)); {
+ case days <= 0:
+ return at.Format("15:04")
+ case days == 1:
+ return "明天 " + at.Format("15:04")
+ case days < 7:
+ return [...]string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"}[at.Weekday()] + " " + at.Format("15:04")
+ }
+ return fmt.Sprintf("%d月%d日 %s", at.Month(), at.Day(), at.Format("15:04"))
+}
+
+// alertText is a usage alert as a notification says it, in the language
+// (en or zh): the card, and what reached the line. left says a window by
+// how much of it is left, as the Usage page does when set to.
+func alertText(lang string, a provider.QuotaAlert, bal float64, left bool, now time.Time) (title, body string) {
+ title = a.Name
+ if a.User != "" {
+ title += " · " + a.User
+ }
+ if a.Window == "" {
+ line := strconv.FormatFloat(bal, 'f', -1, 64)
+ if lang == "zh" {
+ return title, fmt.Sprintf("余额已降至 %s(提醒线 %s)", a.Balance, line)
+ }
+ return title, fmt.Sprintf("Balance down to %s (alert at %s)", a.Balance, line)
+ }
+ n := a.Used
+ if left {
+ n = max(0, 100-n)
+ }
+ pct := strconv.FormatFloat(math.Round(n*10)/10, 'f', -1, 64) + "%"
+ if lang == "zh" {
+ body = fmt.Sprintf("%s窗口已用 %s", alertWindowZh(a.Window), pct)
+ if left {
+ body = fmt.Sprintf("%s窗口剩余 %s", alertWindowZh(a.Window), pct)
+ }
+ if a.ResetsAt != nil {
+ body += "," + alertClockZh(*a.ResetsAt, now) + " 重置"
+ }
+ return title, body
+ }
+ body = fmt.Sprintf("%s: %s used", a.Window, pct)
+ if left {
+ body = fmt.Sprintf("%s: %s left", a.Window, pct)
+ }
+ if a.ResetsAt != nil {
+ body += ", resets " + provider.ResetClock(*a.ResetsAt, now)
+ }
+ return title, body
+}
diff --git a/internal/gui/quota_alert_notify.go b/internal/gui/quota_alert_notify.go
new file mode 100644
index 00000000..8efbeb8f
--- /dev/null
+++ b/internal/gui/quota_alert_notify.go
@@ -0,0 +1,124 @@
+//go:build !nogui
+
+package gui
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/wailsapp/wails/v3/pkg/application"
+ "github.com/wailsapp/wails/v3/pkg/services/notifications"
+ "github.com/yetone/magpie/internal/provider"
+ "github.com/yetone/magpie/internal/settings"
+)
+
+// notifier is the system's notifications, started the first time an alert
+// needs them rather than with the app: started with it, a Mac build that
+// isn't a signed bundle, or a Linux desktop without a notification daemon,
+// would stop the app from starting at all.
+type notifier struct {
+ once sync.Once
+ ns *notifications.NotificationService
+ err error
+ h *host
+ // refused is the Mac's answer when asked this run was no: before it
+ // is asked, not yet allowed and turned off read the same
+ refused atomic.Bool
+}
+
+func (n *notifier) start() error {
+ n.once.Do(func() {
+ ns := notifications.New()
+ // on the main thread, as the app would start it: Windows registers
+ // its toast activator for the thread it is on
+ if err := application.InvokeSyncWithError(func() error {
+ return ns.ServiceStartup(context.Background(), application.ServiceOptions{})
+ }); err != nil {
+ n.err = err
+ log.Println("notifications:", err)
+ return
+ }
+ // a click on one opens the Usage page
+ ns.OnNotificationResponse(func(notifications.NotificationResult) {
+ n.h.whenReady(func() { application.InvokeAsync(func() { n.h.ShowMain("usage") }) })
+ })
+ n.ns = ns
+ })
+ return n.err
+}
+
+// allowed asks for the system's leave to notify where it is asked for (the
+// Mac, which asks once and then keeps the answer).
+func (n *notifier) allowed() bool {
+ if n.start() != nil {
+ return false
+ }
+ if ok, err := n.ns.CheckNotificationAuthorization(); err == nil && ok {
+ return true
+ }
+ ok, err := n.ns.RequestNotificationAuthorization()
+ n.refused.Store(err == nil && !ok)
+ return err == nil && ok
+}
+
+// problem is notifyProblem: why a notification wouldn't be seen.
+func (n *notifier) problem() string {
+ select {
+ case <-n.h.ready:
+ default:
+ return "" // the app isn't running yet
+ }
+ if n.start() != nil {
+ return "unavailable"
+ }
+ if !n.refused.Load() {
+ return ""
+ }
+ if ok, err := n.ns.CheckNotificationAuthorization(); err == nil && !ok {
+ return "denied"
+ }
+ return ""
+}
+
+func (n *notifier) send(title, body string) {
+ if !n.allowed() {
+ return
+ }
+ id := fmt.Sprintf("magpie-alert-%d", time.Now().UnixNano())
+ if err := n.ns.SendNotification(notifications.NotificationOptions{ID: id, Title: title, Body: body}); err != nil {
+ log.Println("notifications:", err)
+ }
+}
+
+// watchAlerts tells the user, with a notification, of a window used past
+// the share or a balance fallen to the amount set in Settings (#368).
+func (h *host) watchAlerts() {
+ n := ¬ifier{h: h}
+ wake := make(chan struct{}, 1)
+ onAlerts = func() {
+ // the Mac's question asked as the alert is turned on, not later
+ // when one is due and the user is elsewhere
+ go n.allowed()
+ select {
+ case wake <- struct{}{}:
+ default:
+ }
+ }
+ notifyProblem = n.problem
+ go func() {
+ <-h.ready
+ // set before this run: asked now, so Settings can say if it was refused
+ if s := settings.Load(); s.UsageAlert > 0 || s.BalanceAlert > 0 {
+ go n.allowed()
+ }
+ provider.WatchQuotas(context.Background(), wake, func(a provider.QuotaAlert) {
+ s := settings.Load()
+ title, body := alertText(trayLang(s.Lang, systemLang), a, s.BalanceAlert, s.QuotaLeft, time.Now())
+ n.send(title, body)
+ })
+ }()
+}
diff --git a/internal/gui/quota_alert_test.go b/internal/gui/quota_alert_test.go
new file mode 100644
index 00000000..6e043e32
--- /dev/null
+++ b/internal/gui/quota_alert_test.go
@@ -0,0 +1,38 @@
+package gui
+
+import (
+ "testing"
+ "time"
+
+ "github.com/yetone/magpie/internal/provider"
+)
+
+func TestAlertText(t *testing.T) {
+ now := time.Date(2026, 10, 1, 9, 0, 0, 0, time.Local)
+ reset := time.Date(2026, 10, 1, 14, 30, 0, 0, time.Local)
+ w := provider.QuotaAlert{Provider: "claude", Name: "Claude Code", User: "a@b.c", Window: "5 hours", Used: 82.34, ResetsAt: &reset}
+ b := provider.QuotaAlert{Provider: "deepseek", Name: "DeepSeek", Balance: "¥4.20"}
+ for _, c := range []struct {
+ lang string
+ a provider.QuotaAlert
+ left bool
+ title, body string
+ }{
+ {"en", w, false, "Claude Code · a@b.c", "5 hours: 82.3% used, resets 14:30"},
+ {"en", w, true, "Claude Code · a@b.c", "5 hours: 17.7% left, resets 14:30"},
+ {"zh", w, false, "Claude Code · a@b.c", "5 小时窗口已用 82.3%,14:30 重置"},
+ {"zh", w, true, "Claude Code · a@b.c", "5 小时窗口剩余 17.7%,14:30 重置"},
+ {"en", b, false, "DeepSeek", "Balance down to ¥4.20 (alert at 5)"},
+ {"zh", b, false, "DeepSeek", "余额已降至 ¥4.20(提醒线 5)"},
+ } {
+ title, body := alertText(c.lang, c.a, 5, c.left, now)
+ if title != c.title || body != c.body {
+ t.Errorf("%s left=%v: %q / %q, want %q / %q", c.lang, c.left, title, body, c.title, c.body)
+ }
+ }
+ tomorrow := reset.Add(24 * time.Hour)
+ week := provider.QuotaAlert{Name: "Codex", Window: "7 days", Used: 80, ResetsAt: &tomorrow}
+ if _, body := alertText("zh", week, 0, false, now); body != "7 天窗口已用 80%,明天 14:30 重置" {
+ t.Errorf("zh tomorrow: %q", body)
+ }
+}
diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md
index 37766162..a0c9dd81 100644
--- a/internal/gui/tests/README.md
+++ b/internal/gui/tests/README.md
@@ -525,10 +525,18 @@ moment; a tab picked while waiting shows its own outline and stays picked;
the Library folder waits. When the library comes the outline goes, the tabs
get their counts and don't move.
+`usage-alert.test.cjs` sets the usage alerts (#368) on the Settings page:
+"Usage alert" and "Low balance alert" start Off; On saves 80% (and 5 for a
+balance), the field beside it saves the number typed and puts back a share
+past 100 unsaved, and a setting saved later (the currency) keeps both
+alerts. With magpie's notifications turned off in the system, both rows say
+so. No click moves the page and no row has a coloured left border; English
+and Chinese, Chromium and WebKit, with `/api/plugins` faked.
+
With Node.js and Playwright available:
```sh
-node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs internal/gui/tests/library-loading.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs internal/gui/tests/plugin-signin.test.cjs internal/gui/tests/plugin-market.test.cjs internal/gui/tests/s3-sync.test.cjs internal/gui/tests/routing-wb-refused.test.cjs internal/gui/tests/routing-shape.test.cjs internal/gui/tests/market-have.test.cjs internal/gui/tests/sessions-today.test.cjs internal/gui/tests/routing-side-calls.test.cjs internal/gui/tests/import-all-skills.test.cjs internal/gui/tests/routing-flood.test.cjs internal/gui/tests/routing-busy.test.cjs internal/gui/tests/own-skill.test.cjs internal/gui/tests/routing-manual.test.cjs internal/gui/tests/tray-usages.test.cjs internal/gui/tests/provider-remove.test.cjs internal/gui/tests/signin-link.test.cjs internal/gui/tests/tray-inuse.test.cjs internal/gui/tests/codex-auto-reset.test.cjs internal/gui/tests/routing-auto-reset.test.cjs internal/gui/tests/claude-resets.test.cjs internal/gui/tests/library-toggle-status.test.cjs internal/gui/tests/library-all.test.cjs internal/gui/tests/routing-reroute-title.test.cjs internal/gui/tests/plain-names.test.cjs internal/gui/tests/panel-effort.test.cjs internal/gui/tests/agent-models-all-hidden.test.cjs internal/gui/tests/omp-roles.test.cjs internal/gui/tests/claude-effort.test.cjs internal/gui/tests/remote-magpie.test.cjs internal/gui/tests/qoder-cn-tile.test.cjs internal/gui/tests/provider-levels-scroll.test.cjs internal/gui/tests/signin-paste-codex.test.cjs
+node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs internal/gui/tests/library-loading.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs internal/gui/tests/plugin-signin.test.cjs internal/gui/tests/plugin-market.test.cjs internal/gui/tests/s3-sync.test.cjs internal/gui/tests/routing-wb-refused.test.cjs internal/gui/tests/routing-shape.test.cjs internal/gui/tests/market-have.test.cjs internal/gui/tests/sessions-today.test.cjs internal/gui/tests/routing-side-calls.test.cjs internal/gui/tests/import-all-skills.test.cjs internal/gui/tests/routing-flood.test.cjs internal/gui/tests/routing-busy.test.cjs internal/gui/tests/own-skill.test.cjs internal/gui/tests/routing-manual.test.cjs internal/gui/tests/tray-usages.test.cjs internal/gui/tests/provider-remove.test.cjs internal/gui/tests/signin-link.test.cjs internal/gui/tests/tray-inuse.test.cjs internal/gui/tests/codex-auto-reset.test.cjs internal/gui/tests/routing-auto-reset.test.cjs internal/gui/tests/claude-resets.test.cjs internal/gui/tests/library-toggle-status.test.cjs internal/gui/tests/library-all.test.cjs internal/gui/tests/routing-reroute-title.test.cjs internal/gui/tests/plain-names.test.cjs internal/gui/tests/panel-effort.test.cjs internal/gui/tests/agent-models-all-hidden.test.cjs internal/gui/tests/omp-roles.test.cjs internal/gui/tests/claude-effort.test.cjs internal/gui/tests/remote-magpie.test.cjs internal/gui/tests/qoder-cn-tile.test.cjs internal/gui/tests/provider-levels-scroll.test.cjs internal/gui/tests/signin-paste-codex.test.cjs internal/gui/tests/usage-alert.test.cjs
```
If Playwright is installed outside the repository, set `NODE_PATH` to the
diff --git a/internal/gui/tests/usage-alert.test.cjs b/internal/gui/tests/usage-alert.test.cjs
new file mode 100644
index 00000000..b2fef3ca
--- /dev/null
+++ b/internal/gui/tests/usage-alert.test.cjs
@@ -0,0 +1,226 @@
+// Run with Node's test runner and Playwright on the module path; see README.md.
+// Usage alerts (#368): the Settings page's "Usage alert" and "Low balance
+// alert" rows start Off; On saves 80% (and 5 for a balance), the field
+// beside it saves the number typed, a share outside 1-100 is put back
+// unsaved, and a choice saved later elsewhere on the page keeps the alerts
+// (the page sends all its settings each time, and the server keeps only
+// what it is sent). When the system has magpie's notifications turned off
+// the rows say so. No click moves the page, and the rows have no coloured
+// left border. 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");
+const { test } = require("node:test");
+const { chromium, webkit } = require("playwright");
+
+const assets = path.resolve(__dirname, "../assets");
+
+function settingsPayload(over) {
+ return {
+ theme: "light", lang: "en", tray: "panel", quotaLeft: false, currency: "usd",
+ dock: false, dockWindow: false, proxy: "", redact: false, redactPersonal: false, redactWords: [],
+ codexWarmup: "", claudeWarmup: "", codexWarmAt: "", claudeWarmAt: "", workbuddyCheckin: false, noStats: false,
+ trayUsage: "", trayUsageEvery: 3, vision: "", imageGen: "",
+ version: "0.1.400", dir: "~/.config/magpie", gateway: "http://127.0.0.1:3425",
+ proxyNow: "none", proxySource: "none", login: false,
+ visionModels: [], imageGenModels: [], workbuddyCheckins: [], lanURLs: [],
+ fx: { rate: 7.2, at: new Date().toISOString(), stale: false },
+ ...over,
+ };
+}
+
+// server answers as magpie does: a POST to /api/settings replaces what is
+// kept with what the page sent, and the notifications' state (problem) is
+// said while an alert is on.
+function server(lang, posts, problem) {
+ const fixed = settingsPayload({ lang });
+ let cur = fixed;
+ const answer = () => ((cur.usageAlert || cur.balanceAlert) && problem ? { ...cur, notifyProblem: problem } : cur);
+ return async (route) => {
+ const req = route.request(), url = new URL(req.url());
+ const json = (data) => route.fulfill({ json: data });
+ if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:false};` });
+ if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
+ if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme: "light" }, fx: fixed.fx });
+ if (url.pathname === "/api/settings") {
+ if (req.method() === "POST") {
+ const body = req.postDataJSON();
+ posts.push(body);
+ cur = { ...fixed, ...body };
+ }
+ return json(answer());
+ }
+ if (url.pathname === "/api/plugins") return json({ plugins: [] });
+ if (url.pathname === "/api/usage/quotas") return json([]);
+ if (url.pathname === "/api/groups") return json({ groups: [], models: [] });
+ if (url.pathname.startsWith("/api/")) return json({});
+ const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
+ const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
+ await route.fulfill({ body: await fs.readFile(file), contentType });
+ };
+}
+
+const view = (page) => page.locator("#view-settings").evaluate((v) => v.scrollTop);
+
+// wheelTo scrolls the settings with the wheel, as a reader would (a script
+// setting scrollTop is put back), until the alert rows are mid-page.
+async function wheelTo(page) {
+ await page.locator("#view-settings").hover();
+ for (let i = 0; i < 40; i++) {
+ const box = await page.locator("#balanceAlertRow").boundingBox();
+ if (box && box.y + box.height < 300 && await view(page) > 0) return;
+ await page.mouse.wheel(0, 120);
+ await page.waitForTimeout(30);
+ }
+ throw new Error("couldn't scroll the alert rows into view");
+}
+
+const words = {
+ en: {
+ usage: "Usage alert", balance: "Low balance alert", off: "Off", on: "On",
+ usageSub: "A notification when a 5-hour, weekly or monthly window reaches this share used, once each time it runs",
+ balanceSub: "A notification when a balance falls to this amount, in its own currency or credits, once until it is topped up",
+ denied: "Notifications are turned off for magpie in the system's settings",
+ share: "Share used", amount: "Amount", cny: "¥ CNY",
+ },
+ zh: {
+ usage: "用量提醒", balance: "余额提醒", off: "关闭", on: "开启",
+ usageSub: "5 小时、每周或每月窗口用到这个比例时发一条系统通知,每个周期只提醒一次",
+ balanceSub: "余额降到这个数(按它自己的货币或积分)时发一条系统通知,充值回升前只提醒一次",
+ denied: "系统设置里关闭了 magpie 的通知",
+ share: "已用比例", amount: "金额", cny: "¥ 人民币",
+ },
+};
+
+for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
+ test(engine + ": usage and balance alerts are set on the Settings page", async (t) => {
+ assert(["chromium", "webkit"].includes(engine), "BROWSER must be chromium or webkit");
+ const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
+ const pages = [];
+ t.after(async () => {
+ if (process.env.ARTIFACT_DIR) {
+ await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
+ for (const [i, p] of pages.entries()) await p.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-usage-alert-${i}.png`) });
+ }
+ await browser.close();
+ });
+
+ for (const lang of ["en", "zh"]) {
+ const w = words[lang];
+ await t.test(lang, async () => {
+ const errors = [], posts = [];
+ const context = await browser.newContext({ viewport: { width: 900, height: 480 }, reducedMotion: "reduce" });
+ const page = await context.newPage();
+ pages.push(page);
+ page.setDefaultTimeout(5000);
+ page.on("pageerror", (e) => errors.push(e.message));
+ await page.route("**/*", server(lang, posts, ""));
+ const posted = async (n) => { for (let i = 0; i < 100 && posts.length < n; i++) await page.waitForTimeout(20); assert.equal(posts.length, n, "posts"); return posts[n - 1]; };
+ const settled = () => page.waitForTimeout(300);
+
+ await page.goto("http://magpie.test/?view=settings");
+ const usageSegs = page.locator("#usageAlertSegs .opt"), balSegs = page.locator("#balanceAlertSegs .opt");
+ await usageSegs.first().waitFor();
+
+ // the rows, in the page's language, Off to begin with
+ assert.equal((await page.locator("#usageAlertRow .name").textContent()).trim(), w.usage);
+ assert.equal((await page.locator("#balanceAlertRow .name").textContent()).trim(), w.balance);
+ assert.equal((await page.locator("#usageAlertSub").textContent()).trim(), w.usageSub);
+ assert.equal((await page.locator("#balanceAlertSub").textContent()).trim(), w.balanceSub);
+ assert.deepEqual(await usageSegs.allTextContents(), [w.off, w.on]);
+ assert.equal(await page.locator("#usageAlertSegs .opt.on").textContent(), w.off);
+ assert.equal(await page.locator("#balanceAlertSegs .opt.on").textContent(), w.off);
+ assert.equal(await page.locator("#usageAlertSegs input, #balanceAlertSegs input").count(), 0, "no field while off");
+
+ // scrolled down with the wheel (the rows to the middle, clear of
+ // the footer), turning it on moves nothing
+ await wheelTo(page);
+ const before = await view(page);
+ assert(before > 0, "the settings list must be long enough to scroll");
+ await usageSegs.nth(1).click();
+ assert.equal((await posted(1)).usageAlert, 80, "On saves 80%");
+ const share = page.locator("#usageAlertSegs input");
+ await share.waitFor();
+ await settled();
+ assert.equal(await view(page), before, "turning the alert on must not scroll the settings page");
+ assert.equal(await share.inputValue(), "80");
+ assert.equal(await share.getAttribute("aria-label"), w.share);
+ assert.equal((await page.locator("#usageAlertSegs .unit").textContent()), "%");
+ assert.equal(await page.locator("#usageAlertSegs .opt.on").textContent(), w.on);
+
+ // a share typed in is saved; one past 100 is put back, unsaved
+ await share.fill("90");
+ await share.press("Enter");
+ assert.equal((await posted(2)).usageAlert, 90);
+ await settled();
+ await share.fill("150");
+ await share.press("Enter");
+ await settled();
+ assert.equal(posts.length, 2, "150% is not saved");
+ assert.equal(await share.inputValue(), "90");
+
+ // the balance: On saves 5, then the amount typed
+ await balSegs.nth(1).click();
+ const b = await posted(3);
+ assert.equal(b.balanceAlert, 5);
+ assert.equal(b.usageAlert, 90, "the usage alert is sent along");
+ const amount = page.locator("#balanceAlertSegs input");
+ await amount.waitFor();
+ assert.equal(await amount.getAttribute("aria-label"), w.amount);
+ await amount.fill("2.5");
+ await amount.press("Enter");
+ assert.equal((await posted(4)).balanceAlert, 2.5);
+ await settled();
+
+ // another setting saved later keeps both alerts
+ await page.locator("#currencySegs .opt", { hasText: w.cny }).click();
+ const c = await posted(5);
+ assert.equal(c.currency, "cny");
+ assert.equal(c.usageAlert, 90, "the usage alert is kept by another save");
+ assert.equal(c.balanceAlert, 2.5, "the balance alert is kept by another save");
+ await settled();
+ assert.equal(await share.inputValue(), "90");
+ assert.equal(await view(page), before, "no click moved the page");
+
+ // Off saves 0, and the field goes
+ await usageSegs.nth(0).click();
+ assert.equal((await posted(6)).usageAlert, 0);
+ await settled();
+ assert.equal(await page.locator("#usageAlertSegs input").count(), 0);
+
+ // no coloured stripe down a row's left
+ for (const sel of ["#usageAlertRow", "#balanceAlertRow", "#usageAlertSub", "#balanceAlertSub"]) {
+ assert.equal(await page.locator(sel).evaluate((e) => getComputedStyle(e).borderLeftWidth), "0px", sel);
+ }
+ assert.deepEqual(errors, []);
+ await context.close();
+ });
+
+ await t.test(lang + ": notifications turned off in the system", async () => {
+ const errors = [], posts = [];
+ const context = await browser.newContext({ viewport: { width: 900, height: 480 }, reducedMotion: "reduce" });
+ const page = await context.newPage();
+ pages.push(page);
+ page.setDefaultTimeout(5000);
+ page.on("pageerror", (e) => errors.push(e.message));
+ await page.route("**/*", server(lang, posts, "denied"));
+ await page.goto("http://magpie.test/?view=settings");
+ await page.locator("#usageAlertSegs .opt").first().waitFor();
+ await wheelTo(page);
+ // nothing said while every alert is off
+ assert.equal((await page.locator("#usageAlertSub").textContent()).trim(), w.usageSub);
+ await page.locator("#usageAlertSegs .opt").nth(1).click();
+ await page.locator("#usageAlertSegs input").waitFor();
+ await page.waitForTimeout(300);
+ for (const id of ["#usageAlertSub", "#balanceAlertSub"]) {
+ const sub = page.locator(id);
+ assert((await sub.textContent()).includes(w.denied), `${id} says notifications are off: ${await sub.textContent()}`);
+ assert.equal((await sub.locator(".warn").textContent()), w.denied, "the problem stands out");
+ assert.equal(await sub.evaluate((e) => getComputedStyle(e).borderLeftWidth), "0px");
+ }
+ assert.deepEqual(errors, []);
+ await context.close();
+ });
+ }
+ });
+}
diff --git a/internal/provider/quota_alert.go b/internal/provider/quota_alert.go
new file mode 100644
index 00000000..8ec87f4b
--- /dev/null
+++ b/internal/provider/quota_alert.go
@@ -0,0 +1,223 @@
+package provider
+
+// Usage alerts (#368): a notification when a subscription's or plan's window
+// has reached the share of it the user chose, or a balance has fallen to the
+// amount they chose (settings.UsageAlert, settings.BalanceAlert). Each is
+// said once: a window once each time it runs, a balance once until it is
+// topped up past the amount again. What was said is kept in a file, so a
+// restart doesn't say it again.
+
+import (
+ "context"
+ "encoding/json"
+ "log"
+ "math"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/yetone/magpie/internal/settings"
+)
+
+// QuotaAlert is one thing to tell the user: a window used past the share
+// (Window set) or a balance at or under the amount (Balance set).
+type QuotaAlert struct {
+ Provider string
+ Name string // the card's name, as the Usage page shows it
+ User string // the account or key, when the provider has several
+ Window string // the window's name
+ Used float64
+ ResetsAt *time.Time
+ Balance string // the balance as the vendor tells it
+}
+
+// alertMark is what was said of one window or balance: when, and for a
+// window the reset it was said before, which is how a new run of the window
+// is told from the same one read again.
+type alertMark struct {
+ At time.Time `json:"at"`
+ Until *time.Time `json:"until,omitempty"`
+}
+
+// alertSameRun is how far apart two readings of a window's reset may be and
+// still be the same run of it: Codex's is the reading's time plus the
+// seconds left, and moves a little from one reading to the next.
+const alertSameRun = 10 * time.Minute
+
+// alertKeep is how long the mark of a window or balance no longer read
+// (signed out, a key removed) is kept.
+const alertKeep = 40 * 24 * time.Hour
+
+// dueAlerts is what the readings qs call for with a window alert at pct
+// percent used (0 off) and a balance alert at bal (0 off), and the marks
+// brought up to date with it. A reading that failed, or is one kept from
+// before (AsOf), neither alerts nor clears: nothing new is known.
+func dueAlerts(qs []SubscriptionQuota, marks map[string]alertMark, pct int, bal float64, now time.Time) []QuotaAlert {
+ var out []QuotaAlert
+ seen := map[string]bool{}
+ for _, q := range qs {
+ if q.Error != "" || q.AsOf != nil {
+ for k := range marks {
+ if strings.HasPrefix(k, alertPrefix(q)) {
+ seen[k] = true
+ }
+ }
+ continue
+ }
+ for _, w := range q.Windows {
+ if w.Aside {
+ continue // using it up doesn't stop the account
+ }
+ key := alertPrefix(q) + "w|" + w.Name + "|" + w.Model
+ seen[key] = true
+ resets := w.ResetsAt
+ if resets == nil && w.ResetSecs > 0 {
+ t := now.Add(time.Duration(w.ResetSecs) * time.Second)
+ resets = &t
+ }
+ if pct <= 0 || w.Used < float64(pct) {
+ delete(marks, key)
+ continue
+ }
+ if m, ok := marks[key]; ok && sameRun(m.Until, resets) {
+ continue
+ }
+ marks[key] = alertMark{At: now, Until: resets}
+ out = append(out, QuotaAlert{Provider: q.Provider, Name: q.Name, User: q.User, Window: w.Name, Used: w.Used, ResetsAt: resets})
+ }
+ if q.Balance == "" {
+ continue
+ }
+ key := alertPrefix(q) + "b"
+ seen[key] = true
+ n, ok := BalanceNumber(q.Balance)
+ if !ok {
+ continue
+ }
+ if bal <= 0 || n > bal {
+ delete(marks, key)
+ continue
+ }
+ if _, ok := marks[key]; ok {
+ continue
+ }
+ marks[key] = alertMark{At: now}
+ out = append(out, QuotaAlert{Provider: q.Provider, Name: q.Name, User: q.User, Balance: q.Balance})
+ }
+ // one not read at all now is kept a while, in case it is read again
+ for k, m := range marks {
+ if !seen[k] && now.Sub(m.At) > alertKeep {
+ delete(marks, k)
+ }
+ }
+ return out
+}
+
+// alertPrefix begins the keys of one card's marks: its provider and account.
+func alertPrefix(q SubscriptionQuota) string { return q.Provider + "|" + q.User + "|" }
+
+// sameRun is whether a window marked as resetting at was and one read now
+// resetting at now are one run of it. A window that says no reset is taken
+// for the same run until its use falls under the share.
+func sameRun(was, now *time.Time) bool {
+ if was == nil || now == nil {
+ return was == nil && now == nil
+ }
+ d := was.Sub(*now)
+ return d < alertSameRun && d > -alertSameRun
+}
+
+var balanceNum = regexp.MustCompile(`-?\d[\d,]*(?:\.\d+)?`)
+
+// BalanceNumber is the amount a balance as a vendor tells it holds: "$12.34",
+// "¥1,024.50", "CNY 8", "12.3k credits". A share ("45%"), or text with no
+// number, is none.
+func BalanceNumber(s string) (float64, bool) {
+ if strings.Contains(s, "%") {
+ return 0, false
+ }
+ loc := balanceNum.FindStringIndex(s)
+ if loc == nil {
+ return 0, false
+ }
+ n, err := strconv.ParseFloat(strings.ReplaceAll(s[loc[0]:loc[1]], ",", ""), 64)
+ if err != nil || math.IsNaN(n) || math.IsInf(n, 0) {
+ return 0, false
+ }
+ switch rest := strings.TrimSpace(s[loc[1]:]); {
+ case strings.HasPrefix(rest, "k") || strings.HasPrefix(rest, "K"):
+ n *= 1e3
+ case strings.HasPrefix(rest, "M"):
+ n *= 1e6
+ }
+ return n, true
+}
+
+func quotaAlertPath() string { return filepath.Join(filepath.Dir(Path()), "usage-alerts.json") }
+
+// quotaAlertMu keeps the file to one writer in this magpie.
+var quotaAlertMu sync.Mutex
+
+func readAlertMarks(path string) map[string]alertMark {
+ m := map[string]alertMark{}
+ if b, err := os.ReadFile(path); err == nil {
+ json.Unmarshal(b, &m)
+ }
+ return m
+}
+
+// checkAlerts reads what is left everywhere and says what is due, keeping
+// the marks in path.
+func checkAlerts(path string, qs []SubscriptionQuota, pct int, bal float64, now time.Time) []QuotaAlert {
+ quotaAlertMu.Lock()
+ defer quotaAlertMu.Unlock()
+ marks := readAlertMarks(path)
+ before, _ := json.Marshal(marks)
+ out := dueAlerts(qs, marks, pct, bal, now)
+ if after, _ := json.Marshal(marks); string(after) != string(before) {
+ os.MkdirAll(filepath.Dir(path), 0o755)
+ os.WriteFile(path, append(after, '\n'), 0o600)
+ }
+ return out
+}
+
+// quotaAlertEvery is how often the allowances are read for the alerts: the
+// vendors' private endpoints limit how often they are asked, and the
+// windows move slowly.
+const quotaAlertEvery = 5 * time.Minute
+
+// WatchQuotas reads every allowance and balance while an alert is set, and
+// hands send each one due, until ctx ends. wake, when it gets a value,
+// has it look at once (an alert just turned on).
+func WatchQuotas(ctx context.Context, wake <-chan struct{}, send func(QuotaAlert)) {
+ t := time.NewTimer(time.Minute)
+ defer t.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-t.C:
+ case <-wake:
+ }
+ if s := settings.Load(); s.UsageAlert > 0 || s.BalanceAlert > 0 {
+ cx, cancel := context.WithTimeout(ctx, time.Minute)
+ qs := Quotas(cx)
+ cancel()
+ for _, a := range checkAlerts(quotaAlertPath(), qs, s.UsageAlert, s.BalanceAlert, time.Now()) {
+ log.Printf("usage alert: %s %s %s %.0f%% %s", a.Name, a.User, a.Window, a.Used, a.Balance)
+ send(a)
+ }
+ }
+ if !t.Stop() {
+ select {
+ case <-t.C:
+ default:
+ }
+ }
+ t.Reset(quotaAlertEvery)
+ }
+}
diff --git a/internal/provider/quota_alert_test.go b/internal/provider/quota_alert_test.go
new file mode 100644
index 00000000..2bb70019
--- /dev/null
+++ b/internal/provider/quota_alert_test.go
@@ -0,0 +1,148 @@
+package provider
+
+import (
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestDueAlertsWindowOncePerRun(t *testing.T) {
+ now := time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC)
+ reset := now.Add(3 * time.Hour)
+ card := func(used float64, resets time.Time) []SubscriptionQuota {
+ return []SubscriptionQuota{{Provider: "codex", Name: "Codex", User: "a@b.c", Windows: []QuotaWindow{
+ {Name: "5 hours", Used: used, ResetsAt: &resets},
+ {Name: "On-demand", Used: 100, Aside: true}, // set aside: never told
+ }}}
+ }
+ marks := map[string]alertMark{}
+ if got := dueAlerts(card(79.9, reset), marks, 80, 0, now); len(got) != 0 {
+ t.Fatalf("under the share: %+v", got)
+ }
+ got := dueAlerts(card(80, reset), marks, 80, 0, now)
+ if len(got) != 1 || got[0].Window != "5 hours" || got[0].User != "a@b.c" || got[0].Used != 80 || !got[0].ResetsAt.Equal(reset) {
+ t.Fatalf("at the share: %+v", got)
+ }
+ // read again, the reset a few seconds off (Codex's moves): the same run
+ if got := dueAlerts(card(95, reset.Add(20*time.Second)), marks, 80, 0, now.Add(5*time.Minute)); len(got) != 0 {
+ t.Fatalf("told twice in one run: %+v", got)
+ }
+ // the window reset and was used up to the share again: told again
+ next := reset.Add(5 * time.Hour)
+ if got := dueAlerts(card(85, next), marks, 80, 0, reset.Add(time.Hour)); len(got) != 1 {
+ t.Fatalf("a new run: %+v", got)
+ }
+ // falling under the share clears it, in the same run too
+ dueAlerts(card(10, next), marks, 80, 0, reset.Add(2*time.Hour))
+ if len(marks) != 0 {
+ t.Fatalf("marks kept under the share: %v", marks)
+ }
+ if got := dueAlerts(card(81, next), marks, 80, 0, reset.Add(3*time.Hour)); len(got) != 1 {
+ t.Fatalf("back over the share: %+v", got)
+ }
+ // off: nothing, and nothing kept
+ if got := dueAlerts(card(100, next), marks, 0, 0, reset.Add(4*time.Hour)); len(got) != 0 || len(marks) != 0 {
+ t.Fatalf("off: %+v %v", got, marks)
+ }
+}
+
+func TestDueAlertsSecondsLeft(t *testing.T) {
+ now := time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC)
+ q := func(secs int64) []SubscriptionQuota {
+ return []SubscriptionQuota{{Provider: "codex", Windows: []QuotaWindow{{Name: "7 days", Used: 90, ResetSecs: secs}}}}
+ }
+ marks := map[string]alertMark{}
+ got := dueAlerts(q(3600), marks, 80, 0, now)
+ if len(got) != 1 || got[0].ResetsAt == nil || !got[0].ResetsAt.Equal(now.Add(time.Hour)) {
+ t.Fatalf("reset from the seconds left: %+v", got)
+ }
+ if got := dueAlerts(q(3000), marks, 80, 0, now.Add(10*time.Minute)); len(got) != 0 {
+ t.Fatalf("the same run, counted down: %+v", got)
+ }
+}
+
+func TestDueAlertsStaleReadingKeepsMarks(t *testing.T) {
+ now := time.Now()
+ reset := now.Add(time.Hour)
+ ok := []SubscriptionQuota{{Provider: "claude", User: "x", Windows: []QuotaWindow{{Name: "5 hours", Used: 90, ResetsAt: &reset}}}}
+ marks := map[string]alertMark{}
+ if len(dueAlerts(ok, marks, 80, 0, now)) != 1 {
+ t.Fatal("not told")
+ }
+ // a failed reading, and one kept from before, say nothing new
+ failed := []SubscriptionQuota{{Provider: "claude", User: "x", Error: "429"}}
+ if got := dueAlerts(failed, marks, 80, 0, now.Add(alertKeep+time.Hour)); len(got) != 0 || len(marks) != 1 {
+ t.Fatalf("failed reading: %+v %v", got, marks)
+ }
+ stale := []SubscriptionQuota{{Provider: "claude", User: "x", AsOf: &now, Windows: []QuotaWindow{{Name: "5 hours", Used: 10, ResetsAt: &reset}}}}
+ if dueAlerts(stale, marks, 80, 0, now); len(marks) != 1 {
+ t.Fatalf("a stale reading cleared the mark: %v", marks)
+ }
+ if len(dueAlerts(ok, marks, 80, 0, now)) != 0 {
+ t.Fatal("told again after a failed reading")
+ }
+ // one no longer read at all is let go after a while
+ dueAlerts(nil, marks, 80, 0, now.Add(alertKeep+time.Hour))
+ if len(marks) != 0 {
+ t.Fatalf("kept forever: %v", marks)
+ }
+}
+
+func TestDueAlertsBalance(t *testing.T) {
+ now := time.Now()
+ q := func(b string) []SubscriptionQuota {
+ return []SubscriptionQuota{{Provider: "deepseek", Name: "DeepSeek", Balance: b, Windows: []QuotaWindow{}}}
+ }
+ marks := map[string]alertMark{}
+ if got := dueAlerts(q("¥5.01"), marks, 80, 5, now); len(got) != 0 {
+ t.Fatalf("over the amount: %+v", got)
+ }
+ got := dueAlerts(q("¥5.00"), marks, 80, 5, now)
+ if len(got) != 1 || got[0].Balance != "¥5.00" || got[0].Window != "" {
+ t.Fatalf("at the amount: %+v", got)
+ }
+ if got := dueAlerts(q("¥1.20"), marks, 80, 5, now); len(got) != 0 {
+ t.Fatalf("told twice: %+v", got)
+ }
+ // topped up, then down again
+ dueAlerts(q("¥100.00"), marks, 80, 5, now)
+ if got := dueAlerts(q("¥4.00"), marks, 80, 5, now); len(got) != 1 {
+ t.Fatalf("down again: %+v", got)
+ }
+ // a share isn't an amount, and off is off
+ if got := dueAlerts(q("3%"), map[string]alertMark{}, 80, 5, now); len(got) != 0 {
+ t.Fatalf("a share: %+v", got)
+ }
+ if got := dueAlerts(q("$0.10"), map[string]alertMark{}, 80, 0, now); len(got) != 0 {
+ t.Fatalf("off: %+v", got)
+ }
+}
+
+func TestBalanceNumber(t *testing.T) {
+ for s, want := range map[string]float64{
+ "$12.34": 12.34, "¥1,024.50": 1024.5, "CNY 8": 8, "12.3k credits": 12300, "$-3.20": -3.2, "2M tokens": 2e6,
+ } {
+ if n, ok := BalanceNumber(s); !ok || n != want {
+ t.Errorf("BalanceNumber(%q) = %v, %v; want %v", s, n, ok, want)
+ }
+ }
+ for _, s := range []string{"45%", "", "unlimited"} {
+ if _, ok := BalanceNumber(s); ok {
+ t.Errorf("BalanceNumber(%q) read a number", s)
+ }
+ }
+}
+
+// what was told is kept across a restart: the file, not memory
+func TestCheckAlertsKeepsMarks(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "usage-alerts.json")
+ now := time.Now()
+ reset := now.Add(time.Hour)
+ qs := []SubscriptionQuota{{Provider: "claude", User: "x", Windows: []QuotaWindow{{Name: "5 hours", Used: 90, ResetsAt: &reset}}}}
+ if got := checkAlerts(path, qs, 80, 0, now); len(got) != 1 {
+ t.Fatalf("first: %+v", got)
+ }
+ if got := checkAlerts(path, qs, 80, 0, now.Add(time.Minute)); len(got) != 0 {
+ t.Fatalf("told again after the file was read back: %+v", got)
+ }
+}
diff --git a/internal/settings/settings.go b/internal/settings/settings.go
index 66a0d02b..fa453baf 100644
--- a/internal/settings/settings.go
+++ b/internal/settings/settings.go
@@ -119,6 +119,15 @@ type Settings struct {
// QuotaLeft shows a subscription's windows by how much of each is left,
// not used: the Usage page, the tray panel and the menu bar alike.
QuotaLeft bool `json:"quotaLeft,omitempty"`
+ // UsageAlert is how much of a subscription's or plan's window, in
+ // percent, is used when magpie says so with a notification (#368):
+ // once for each time the window runs, for every window routing counts
+ // (not one set aside, as on-demand spending is); 0 is off.
+ UsageAlert int `json:"usageAlert,omitempty"`
+ // BalanceAlert is the balance a key or account has fallen to when
+ // magpie says so, in that balance's own currency or credits, once until
+ // it is topped up past it again; 0 is off.
+ BalanceAlert float64 `json:"balanceAlert,omitempty"`
// PlainNames has the model lists magpie gives agents name each model
// by its name alone, without its provider's or "routing group" after it
// (#335) — but for two in one list that would read the same, which keep
@@ -474,6 +483,12 @@ func Save(s Settings) error {
if !slices.Contains(TrayEvery, s.TrayUsageEvery) {
return fmt.Errorf("the menu bar's usage is refreshed every %v minutes, not %d", TrayEvery, s.TrayUsageEvery)
}
+ if s.UsageAlert < 0 || s.UsageAlert > 100 {
+ return fmt.Errorf("a usage alert is at a percentage from 1 to 100, or 0 for off, not %d", s.UsageAlert)
+ }
+ if math.IsNaN(s.BalanceAlert) || math.IsInf(s.BalanceAlert, 0) || s.BalanceAlert < 0 {
+ return fmt.Errorf("a balance alert is at an amount of 0 or more (0 for off), not %v", s.BalanceAlert)
+ }
if !slices.Contains(TextSizes, s.TextSize) {
return fmt.Errorf("text size must be one of %v percent, not %d", TextSizes, s.TextSize)
}
diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go
index cb8b48f7..60baf7b9 100644
--- a/internal/settings/settings_test.go
+++ b/internal/settings/settings_test.go
@@ -483,3 +483,19 @@ func TestModelPriceKeysStayTheFilesOwnWhileMessagesSayTheParts(t *testing.T) {
t.Errorf("refusing a price: %v; want it to ask for a cache read price", err)
}
}
+
+// the usage alerts' share and amount (#368): 0 is off, the rest in range
+func TestAlertsChecked(t *testing.T) {
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+ for _, s := range []Settings{{UsageAlert: 101}, {UsageAlert: -1}, {BalanceAlert: -0.5}} {
+ if err := Save(s); err == nil {
+ t.Errorf("saved %+v", s)
+ }
+ }
+ if err := Save(Settings{UsageAlert: 80, BalanceAlert: 2.5}); err != nil {
+ t.Fatal(err)
+ }
+ if s := Load(); s.UsageAlert != 80 || s.BalanceAlert != 2.5 {
+ t.Fatalf("read back %d %v", s.UsageAlert, s.BalanceAlert)
+ }
+}
diff --git a/main.go b/main.go
index d2c0cdc5..212dff63 100644
--- a/main.go
+++ b/main.go
@@ -154,6 +154,11 @@ func run(args []string) error {
return runGUI(true, "")
case "tray":
return runGUI(false, "")
+ case "-Embedding":
+ // Windows starting magpie for a click on one of its notifications
+ // (a usage alert, #368) left in the Action Center after it quit:
+ // the window, on the Usage page
+ return runWindow("usage")
case "panel":
return runPanel()
case "autostart":
diff --git a/quota_cli.go b/quota_cli.go
index 82f5c62f..d18c7c76 100644
--- a/quota_cli.go
+++ b/quota_cli.go
@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"slices"
+ "strconv"
"strings"
"time"
@@ -16,6 +17,7 @@ import (
const quotaUsage = `usage: magpie quota [
…] [--json]
magpie quota reset [] [--yes]
magpie quota auto-reset [] [on|off]
+ magpie quota alert [|off] [--balance |off]
what is left of every subscription, plan and key magpie has: each window's use and
when it starts again, and each key's balance, asked of the vendors now (or less than
a minute ago). --json is for scripts and agents; the gateway answers the same at
@@ -25,7 +27,12 @@ const quotaUsage = `usage: magpie quota […] [--json]
named). It can't be undone, so it asks first; --yes doesn't. quota auto-reset on lets
the account spend one by itself when its weekly window is used up and no other
account can take a request, one a week at most (the five hours running out never
- does); off stops it, and alone it says which accounts do. It is off until turned on.`
+ does); off stops it, and alone it says which accounts do. It is off until turned on.
+ quota alert 80 has the magpie app notify when any window of a subscription or plan
+ reaches 80% used, once each time the window runs (not windows set aside, such as
+ on-demand spending); --balance 5 when a balance falls to 5 or under, in its own
+ currency or credits, once until topped up past it. off turns either off, and alone
+ it says what is set. Both are off until set.`
// quotaCmd: magpie quota […] [--json]
func quotaCmd(args []string) error {
@@ -35,6 +42,9 @@ func quotaCmd(args []string) error {
if len(args) > 1 && args[1] == "auto-reset" {
return quotaAutoResetCmd(args[2:])
}
+ if len(args) > 1 && args[1] == "alert" {
+ return quotaAlertCmd(args[2:])
+ }
asJSON := false
var only []string
for _, a := range args[1:] {
@@ -232,6 +242,70 @@ func quotaAutoResetCmd(args []string) error {
return nil
}
+// quotaAlertCmd: magpie quota alert [|off] [--balance |off]
+// — the usage alerts Settings sets (#368), the notifications the app shows.
+func quotaAlertCmd(args []string) error {
+ s := settings.Load()
+ changed := false
+ for i := 0; i < len(args); i++ {
+ a := strings.ToLower(strings.TrimSpace(args[i]))
+ switch {
+ case a == "help" || a == "-h" || a == "--help":
+ fmt.Println(quotaUsage)
+ return nil
+ case a == "--balance" || strings.HasPrefix(a, "--balance="):
+ v, ok := strings.CutPrefix(a, "--balance=")
+ if !ok {
+ if i+1 >= len(args) {
+ return fmt.Errorf("--balance needs an amount, or off")
+ }
+ i++
+ v = strings.ToLower(strings.TrimSpace(args[i]))
+ }
+ if v == "off" {
+ s.BalanceAlert = 0
+ } else {
+ n, err := strconv.ParseFloat(v, 64)
+ if err != nil || n <= 0 {
+ return fmt.Errorf("a balance alert is at an amount over 0, or off, not %q", v)
+ }
+ s.BalanceAlert = n
+ }
+ changed = true
+ case a == "off":
+ s.UsageAlert = 0
+ changed = true
+ default:
+ n, err := strconv.Atoi(strings.TrimSuffix(a, "%"))
+ if err != nil || n < 1 || n > 100 {
+ return fmt.Errorf("a usage alert is at a percentage from 1 to 100, or off, not %q", args[i])
+ }
+ s.UsageAlert = n
+ changed = true
+ }
+ }
+ if changed {
+ if err := settings.Save(s); err != nil {
+ return err
+ }
+ fmt.Print(green.Render("✓") + " ")
+ }
+ if s.UsageAlert > 0 {
+ fmt.Printf("notifies when a window reaches %d%% used, once each time it runs", s.UsageAlert)
+ } else {
+ fmt.Print("no usage alert")
+ }
+ if s.BalanceAlert > 0 {
+ fmt.Printf("; and when a balance falls to %s or under, once until topped up\n", strconv.FormatFloat(s.BalanceAlert, 'f', -1, 64))
+ } else {
+ fmt.Println("; no balance alert")
+ }
+ if s.UsageAlert > 0 || s.BalanceAlert > 0 {
+ fmt.Println(muted.Render(" the notifications come from the magpie app, while it runs"))
+ }
+ return nil
+}
+
// quotaTitle is a quota's line head: its provider, plan and account.
func quotaTitle(q provider.Quota) string {
t := q.Provider