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:
7 files changed
+256
-8
No files matched your search
@@ -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); }
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user