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) + } +}