usage: a window reads as how much is used or, as the vendors' own apps show it, how much is left — Used / Left on the Usage page, or a click on the figure, flips every meter and its bar, kept for next time; the label says which (64% used, 36% left). A window with the vendor's own count shows the percentage too (345 / 28000 · 1% used, issue #90). The terminal's usage page shows the accounts too — subscriptions' and plans' windows as meters, keys' balances — asked of the vendors when it opens and on r, u to flip used / left

This commit is contained in:
yetone committed 2026-09-26 23:03:41 +08:00
1 parent 1d064efe4d
commit c08231ee25
7 files changed
+256 -8

No files matched your search

+2 -1
View File
@@ -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); }
+41 -5
View File
@@ -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() });
+5
View File
@@ -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}",
+1
View File
@@ -84,6 +84,7 @@
<div class="usage-head">
<div class="segs" id="period"></div>
<span class="grow"></span>
<div class="segs" id="quotaMode" hidden></div>
<span class="usage-cost" id="usageCost"></span>
</div>
<section class="subscription-usage" id="subscriptionUsage"></section>
+142 -1
View File
@@ -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) {
+15 -1
View File
@@ -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")
+50
View File
@@ -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)
}
}