diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css
index c9f516ee..ef908f66 100644
--- a/internal/gui/assets/app.css
+++ b/internal/gui/assets/app.css
@@ -402,7 +402,8 @@ body.window .field.solo { grid-column: auto; }
.subscription-head .plan { margin-left: auto; font-size: 10.5px; color: var(--muted); text-transform: capitalize; }
.quota-windows { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px 14px; }
.quota-labels { display: flex; align-items: baseline; gap: 6px; margin-bottom: 4px; font-size: 10.5px; color: var(--muted); }
-.quota-labels b { margin-left: auto; color: var(--fg-2); font-weight: 600; font-variant-numeric: tabular-nums; }
+.quota-labels .quota-n { margin-left: auto; padding: 0; color: var(--fg-2); font-size: inherit; font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
+.quota-labels .quota-n:hover { color: var(--fg); }
.quota-track { height: 4px; border-radius: 2px; background: var(--pill); overflow: hidden; }
.quota-track i { display: block; height: 100%; border-radius: inherit; background: var(--accent); }
.subscription-error { font-size: 11px; color: var(--faint); }
diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js
index eaa1b72a..39086df1 100644
--- a/internal/gui/assets/app.js
+++ b/internal/gui/assets/app.js
@@ -3165,15 +3165,15 @@ function accountQuota(data, user) {
return line;
}
// the two rolling windows fit a line; the per-model ones go in its tooltip
- line.title = q.windows.slice(2).map((w) => t(w.name) + " " + (w.display || t("{n} used", { n: Math.round(w.used) + "%" }))).join(" · ");
+ line.title = q.windows.slice(2).map((w) => t(w.name) + " " + quotaText(w)).join(" · ");
for (const w of q.windows.slice(0, 2)) {
const used = Math.max(0, Math.min(100, w.used));
const m = el("span", "aq-w" + (used >= 90 ? " full" : ""));
const track = el("span", "aq-track");
const fill = el("i");
- fill.style.width = used + "%";
+ fill.style.width = quotaFill(w) + "%";
track.append(fill);
- m.append(el("span", "aq-n", t(w.name)), track, el("b", "", w.display || t("{n} used", { n: Math.round(w.used) + "%" })));
+ m.append(el("span", "aq-n", t(w.name)), track, el("b", "", quotaText(w)));
if (w.resetsAt) {
const at = new Date(w.resetsAt);
m.title = t("Resets {when}", { when: at.toLocaleString() });
@@ -3184,6 +3184,26 @@ function accountQuota(data, user) {
return line;
}
+// A window reads as how much of it is used, or — as the vendors' own apps
+// show it — how much is left, the bar filling with that; one choice for
+// every meter, kept for next time. The vendor's own count, where it gives
+// one, stands before the percentage.
+let quotaLeft = false;
+try { quotaLeft = localStorage.getItem("magpie.quotaLeft") === "1"; } catch {}
+function quotaFill(w) {
+ const used = Math.round(Math.max(0, Math.min(100, w.used)));
+ return quotaLeft ? 100 - used : used;
+}
+function quotaText(w) {
+ const pct = t(quotaLeft ? "{n} left" : "{n} used", { n: quotaFill(w) + "%" });
+ return w.display ? w.display + " · " + pct : pct;
+}
+function setQuotaLeft(on) {
+ quotaLeft = on;
+ try { localStorage.setItem("magpie.quotaLeft", on ? "1" : "0"); } catch {}
+ renderQuotas();
+}
+
function untilText(at) {
const mins = Math.max(1, Math.round((at - Date.now()) / 60000));
if (mins < 60) return t("in {n}m", { n: mins });
@@ -3520,6 +3540,19 @@ const tokensOf = (t) => t.input + t.output;
function renderQuotas() {
const subscriptions = $("#subscriptionUsage");
subscriptions.replaceChildren();
+ // used or left: only there when some card has a window to read
+ const mode = $("#quotaMode");
+ mode.hidden = !quotas?.some((q) => !q.balance && !q.error && q.windows?.length);
+ if (!mode.hidden) {
+ mode.replaceChildren();
+ for (const [left, name] of [[false, "Used"], [true, "Left"]]) {
+ const b = el("button", "opt" + (left === quotaLeft ? " on" : ""), t(name));
+ b.title = t(left ? "Show how much of each window is left" : "Show how much of each window is used");
+ b.onclick = () => { if (left !== quotaLeft) setQuotaLeft(left); };
+ mode.append(b);
+ }
+ slide(mode, "quotaMode");
+ }
if (!quotas) {
subscriptions.hidden = false;
for (let i = 0; i < 2; i++) {
@@ -3574,10 +3607,13 @@ function quotaWindows(sub) {
for (const w of sub.windows) {
const quota = el("div", "quota");
const labels = el("div", "quota-labels");
- labels.append(el("span", "", t(w.name)), el("b", "", w.display || t("{n} used", { n: `${Math.round(w.used)}%` })));
+ const n = el("button", "quota-n", quotaText(w));
+ n.title = t(quotaLeft ? "Show how much of each window is used" : "Show how much of each window is left");
+ n.onclick = () => setQuotaLeft(!quotaLeft);
+ labels.append(el("span", "", t(w.name)), n);
const track = el("div", "quota-track");
const fill = el("i");
- fill.style.width = `${Math.max(0, Math.min(100, w.used))}%`;
+ fill.style.width = `${quotaFill(w)}%`;
track.append(fill);
quota.append(labels, track);
if (w.resetsAt) quota.title = t("Resets {when}", { when: new Date(w.resetsAt).toLocaleString() });
diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js
index 89c0a7a1..32500af0 100644
--- a/internal/gui/assets/i18n.js
+++ b/internal/gui/assets/i18n.js
@@ -792,6 +792,11 @@ const I18N = {
"{n} used · kept for last": "已用 {n} · 留到最后",
"{n} used · renews in {d}": "已用 {n} · {d}后重置",
"{n} used": "已用 {n}",
+ "{n} left": "剩余 {n}",
+ "Used": "已用",
+ "Left": "剩余",
+ "Show how much of each window is used": "显示每个额度窗口已用多少",
+ "Show how much of each window is left": "显示每个额度窗口还剩多少",
"what's left not known yet": "剩余额度未知",
"{n} tokens lately": "最近 {n} token",
"{api} only": "仅 {api}",
diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html
index 39a03b6f..3f4d1b2c 100644
--- a/internal/gui/assets/index.html
+++ b/internal/gui/assets/index.html
@@ -84,6 +84,7 @@
diff --git a/internal/tui/pages.go b/internal/tui/pages.go
index 3bcd0405..2bedf91a 100644
--- a/internal/tui/pages.go
+++ b/internal/tui/pages.go
@@ -7,6 +7,7 @@ package tui
import (
"context"
"fmt"
+ "math"
"slices"
"strings"
"sync"
@@ -743,9 +744,22 @@ var periods = []usage.Period{usage.Today, usage.Week, usage.Month, usage.All}
var periodNames = map[usage.Period]string{usage.Today: "today", usage.Week: "7 days", usage.Month: "30 days", usage.All: "all time"}
+// quotaMsg is what is left of every subscription, plan bought with a key
+// and key balance, as the app's Usage page shows them.
+type quotaMsg []provider.SubscriptionQuota
+
+func quotasCmd() tea.Msg {
+ ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
+ defer cancel()
+ return quotaMsg(provider.Quotas(ctx))
+}
+
func (m model) updateUsage(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
i := slices.Index(periods, m.period)
switch msg.String() {
+ case "u":
+ m.qleft = !m.qleft
+ return m, nil
case "l", "right":
m.period = periods[(i+1)%len(periods)]
case "h", "left":
@@ -816,6 +830,13 @@ func (m model) viewUsage() string {
}
}
b.WriteString(pad + " " + strings.Join(tabs, " ") + "\n\n")
+ ql := quotaLines(m.quotas, m.qasked, m.qleft, m.w-len(pad)-2, time.Now())
+ for _, l := range ql {
+ b.WriteString(pad + " " + l + "\n")
+ }
+ if len(ql) > 0 {
+ b.WriteString("\n")
+ }
if s.Calls == 0 {
b.WriteString(pad + " " + sMuted.Render("no calls in this time · route an agent through magpie and its usage shows up here"))
return b.String()
@@ -849,7 +870,7 @@ func (m model) viewUsage() string {
for _, a := range agent.All() {
names[a.ID] = a.Name
}
- room := max(2, (m.h-16)/2)
+ room := max(2, (m.h-16-len(ql))/2)
table := func(head string, gs []usage.Group, name func(usage.Group) string) {
b.WriteString(pad + " " + sFaint.Render(head) + "\n")
w := 0
@@ -876,6 +897,126 @@ func (m model) viewUsage() string {
return strings.TrimRight(b.String(), "\n")
}
+// quotaLines is the accounts' allowances, a line each as the app's cards
+// are: the subscriptions' and plans' windows as small meters — how much is
+// used, or left, with the vendor's own count before it where it gives one
+// — and the keys' balances; nil when there is nothing to tell.
+func quotaLines(qs []provider.SubscriptionQuota, asked, left bool, width int, now time.Time) []string {
+ if qs == nil {
+ if !asked {
+ return nil
+ }
+ return []string{sFaint.Render("accounts"), sMuted.Render("asking the vendors what is left…")}
+ }
+ if len(qs) == 0 {
+ return nil
+ }
+ title := func(q provider.SubscriptionQuota) (plain, styled string) {
+ plain, styled = q.Name, sName.Render(q.Name)
+ if q.Plan != "" {
+ plain += " · " + q.Plan
+ styled += sMuted.Render(" · " + q.Plan)
+ }
+ if q.User != "" {
+ plain += " · " + q.User
+ styled += sFaint.Render(" · " + q.User)
+ }
+ return
+ }
+ tw := 0
+ for _, q := range qs {
+ p, _ := title(q)
+ tw = max(tw, lipgloss.Width(p))
+ }
+ tw = min(tw, 44)
+ head := "accounts · % is how much is used"
+ if left {
+ head = "accounts · % is how much is left"
+ }
+ out := []string{sFaint.Render(head)}
+ for _, q := range qs {
+ p, t := title(q)
+ if lipgloss.Width(p) > tw {
+ t = sName.Render(trunc(p, tw))
+ }
+ line := padRight(t, tw)
+ switch {
+ case q.Balance != "":
+ out = append(out, line+" "+sText.Render(q.Balance)+sMuted.Render(" left"))
+ continue
+ case q.Error != "":
+ out = append(out, line+" "+sMuted.Render(trunc(q.Error, max(20, width-tw-2))))
+ continue
+ case len(q.Windows) == 0:
+ out = append(out, line+" "+sMuted.Render("no usage reported"))
+ continue
+ }
+ // the windows follow the name, those that don't fit on lines below it
+ at := tw
+ for _, w := range q.Windows {
+ c := quotaCell(w, left, now)
+ cw := lipgloss.Width(c)
+ if at > tw && width > 0 && at+3+cw > width {
+ out = append(out, line)
+ line, at = strings.Repeat(" ", tw), tw
+ }
+ line += " " + c
+ at += 3 + cw
+ }
+ out = append(out, line)
+ }
+ return out
+}
+
+// quotaCell is one window: its name, a meter, how much is used or left,
+// and when it starts again once it is nearly used up.
+func quotaCell(w provider.QuotaWindow, left bool, now time.Time) string {
+ used := int(math.Round(math.Max(0, math.Min(100, w.Used))))
+ n, word := used, "used"
+ if left {
+ n, word = 100-used, "left"
+ }
+ const cells = 8
+ on := (n*cells + 50) / 100
+ if n > 0 {
+ on = max(1, on)
+ }
+ fill := sCursor
+ if used >= 90 {
+ fill = sBad
+ }
+ pct := fmt.Sprintf("%d%% %s", n, word)
+ if w.Display != "" {
+ pct = w.Display + " · " + pct
+ }
+ c := sMuted.Render(w.Name) + " " + fill.Render(strings.Repeat("█", on)) + sFaint.Render(strings.Repeat("░", cells-on)) + " " + sText.Render(pct)
+ if used >= 80 {
+ var at time.Time
+ if w.ResetsAt != nil {
+ at = *w.ResetsAt
+ } else if w.ResetSecs > 0 {
+ at = now.Add(time.Duration(w.ResetSecs) * time.Second)
+ }
+ if !at.IsZero() {
+ c += sFaint.Render(" ↻ " + until(at.Sub(now)))
+ }
+ }
+ return c
+}
+
+// until is how long until then, roughly: 40m, 5h, 3d.
+func until(d time.Duration) string {
+ mins := max(1, int(math.Round(d.Minutes())))
+ h := int(math.Round(float64(mins) / 60))
+ switch {
+ case mins < 60:
+ return fmt.Sprintf("%dm", mins)
+ case h < 48:
+ return fmt.Sprintf("%dh", h)
+ }
+ return fmt.Sprintf("%dd", int(math.Round(float64(h)/24)))
+}
+
// ---- a line to type ---------------------------------------------------------
func (m *model) openAsk(a ask) {
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index fd723f8c..4be97788 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -110,6 +110,9 @@ type model struct {
gsel int // its member picked
period usage.Period
sum usage.Summary
+ quotas []provider.SubscriptionQuota // nil while the vendors are asked
+ qasked bool // quotas were asked for
+ qleft bool // windows read as what is left, not what is used
lib []libRow
lrow int
libView *library.View
@@ -173,6 +176,10 @@ func (m *model) goTo(p page) tea.Cmd {
m.asked = true
return balancesCmd(m.provs)
}
+ if p == pageUsage && !m.qasked {
+ m.qasked = true
+ return quotasCmd
+ }
return nil
}
@@ -207,6 +214,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.flash = ""
}
return m, nil
+ case quotaMsg:
+ m.quotas = msg
+ return m, nil
case askMsg:
m.openAsk(msg.a)
return m, nil
@@ -245,6 +255,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.String() == "r" {
m.reload()
m.flash, m.flashOK = "reloaded", true
+ if m.page == pageUsage {
+ m.quotas, m.qasked = nil, true
+ return m, quotasCmd
+ }
return m, nil
}
}
@@ -616,7 +630,7 @@ func (m model) View() string {
footer = hints("↑↓", "item", "↵", "agents", "e", "edit instructions", "i", "bring in", "d", "remove", "r", "reload", "1–5", "pages", "q", "quit")
case pageUsage:
body = m.viewUsage()
- footer = hints("←→", "period", "t w m A", "today · 7 days · 30 days · all", "r", "reload", "1–5", "pages", "q", "quit")
+ footer = hints("←→", "period", "t w m A", "today · 7 days · 30 days · all", "u", "used / left", "r", "reload", "1–5", "pages", "q", "quit")
default:
body = m.viewList()
footer = hints("↑↓", "agent", "←→", "field", "↵", "change", "s", "save profile", "p", "profiles", "1–5", "pages", "q", "quit")
diff --git a/internal/tui/usage_test.go b/internal/tui/usage_test.go
new file mode 100644
index 00000000..2064d1fc
--- /dev/null
+++ b/internal/tui/usage_test.go
@@ -0,0 +1,50 @@
+package tui
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/yetone/magpie/internal/provider"
+)
+
+// The Usage page lists the accounts' windows and balances as the app's
+// cards do, a window reading as used or left, and the vendor's own count
+// beside the percentage.
+func TestQuotaLines(t *testing.T) {
+ now := time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC)
+ reset := now.Add(3 * time.Hour)
+ qs := []provider.SubscriptionQuota{
+ {Name: "Codex", Plan: "Plus", User: "me@example.com", Windows: []provider.QuotaWindow{
+ {Name: "5 hours", Used: 64},
+ {Name: "7 days", Used: 99, ResetsAt: &reset},
+ }},
+ {Name: "ZCode", Windows: []provider.QuotaWindow{{Name: "Month", Used: 1.2, Display: "345 / 28000"}}},
+ {Name: "DeepSeek", Balance: "¥12.30"},
+ {Name: "Gemini", Error: "sign-in has expired"},
+ }
+ plain := func(ls []string) string { return strings.Join(ls, "\n") } // no terminal here, so no colour
+
+ used := plain(quotaLines(qs, true, false, 200, now))
+ for _, want := range []string{"Codex · Plus · me@example.com", "64% used", "99% used ↻ 3h", "345 / 28000 · 1% used", "¥12.30 left", "sign-in has expired"} {
+ if !strings.Contains(used, want) {
+ t.Errorf("used: missing %q in\n%s", want, used)
+ }
+ }
+ left := plain(quotaLines(qs, true, true, 200, now))
+ for _, want := range []string{"36% left", "1% left", "345 / 28000 · 99% left", "% is how much is left"} {
+ if !strings.Contains(left, want) {
+ t.Errorf("left: missing %q in\n%s", want, left)
+ }
+ }
+ // a narrow terminal puts the windows that don't fit on lines of their own
+ if n := len(quotaLines(qs[:1], true, false, 60, now)); n != 3 {
+ t.Errorf("narrow: %d lines, want 3 (head, and a window a line)", n)
+ }
+ if quotaLines(nil, false, false, 80, now) != nil {
+ t.Error("not asked yet: want nothing")
+ }
+ if got := plain(quotaLines(nil, true, false, 80, now)); !strings.Contains(got, "asking the vendors") {
+ t.Errorf("asking: %q", got)
+ }
+}