okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawntui: replace the generic /quota page with a screen of latest limits, bars and per-limit trails8e3e173

main
5.0 KiB128 linesraw
1package tui
2
3import (
4	"errors"
5	"math"
6	"time"
7
8	"albedo/cli/internal/daemon"
9	"albedo/cli/internal/daemon/protocol"
10
11	tea "charm.land/bubbletea/v2"
12)
13
14// quotaNow is the clock every quota shot is drawn at, in UTC so the pictures
15// do not depend on the machine.
16var quotaNow = time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC)
17
18func quotaReading(provider, account, id, label string, percent float64, window string, seconds int64, resetsIn, seenAgo time.Duration) daemon.QuotaObservation {
19	resets := quotaNow.Add(resetsIn).Format(time.RFC3339)
20	return daemon.QuotaObservation{
21		Provider: provider, AccountID: account, LimitID: id, Label: label,
22		UsedPercent: &percent, WindowLabel: &window, WindowSeconds: &seconds,
23		ResetsAt: &resets, Status: "ok", Source: "poll",
24		ObservedAt: quotaNow.Add(-seenAgo).Format(time.RFC3339),
25	}
26}
27
28func quotaFixture() []daemon.QuotaObservation {
29	plan := "Max 5x"
30	five := quotaReading("anthropic", "dawn@example.com", "five_hour", "5 hour", 62, "5 hour", 5*3600, 2*time.Hour+14*time.Minute, 4*time.Minute)
31	five.Plan = &plan
32	week := quotaReading("anthropic", "dawn@example.com", "seven_day", "7 day", 23, "7 day", 7*24*3600, 4*24*time.Hour+3*time.Hour, 4*time.Minute)
33	week.Plan = &plan
34	hyper := quotaReading("hyper", "charm-hyper", "primary", "Primary", 85, "1 hour", 3600, 21*time.Minute, time.Minute)
35	codex := quotaReading("openai-codex", "dawn-chatgpt", "primary", "5 hour", 97, "5 hour", 5*3600, 38*time.Minute, 2*time.Minute)
36	stale := quotaReading("google-antigravity", "dawn@gmail.com", "gemini", "Gemini", 41, "1 day", 24*3600, 20*time.Hour, 3*time.Hour)
37	broken := daemon.QuotaObservation{
38		Provider: "deepseek", AccountID: "deepseek", LimitID: "balance", Label: "Balance",
39		Status: "error", Source: "poll", ObservedAt: quotaNow.Add(-6 * time.Minute).Format(time.RFC3339),
40		Error: &protocol.SafeReason{Code: "quota_failed", Detail: "the provider answered 401: the API key was rejected"},
41	}
42	return []daemon.QuotaObservation{five, week, hyper, codex, stale, broken}
43}
44
45// quotaTrailFixture is a limit's readings every ten minutes over its trail:
46// in each window the percent climbs from nothing toward that window's peak
47// and falls back at the reset. The newest window ends at the reading itself.
48func quotaTrailFixture(r daemon.QuotaObservation) []quotaPoint {
49	window := float64(*r.WindowSeconds) * float64(time.Second)
50	resets, _ := resetOf(r)
51	seen, _ := parseStamp(r.ObservedAt)
52	// windowsBack counts whole windows between at and the one the reset closes.
53	phase := func(at time.Time) (back int, progress float64) {
54		x := float64(resets.Sub(at)) / window
55		return int(math.Floor(x)), 1 - (x - math.Floor(x))
56	}
57	_, now := phase(seen)
58	peaks := []float64{0, 71, 44, 88, 52, 36, 64, 79}
59	var points []quotaPoint
60	for at := seen.Add(-quotaTrailSpan(r)); !at.After(seen); at = at.Add(10 * time.Minute) {
61		back, progress := phase(at)
62		peak := peaks[back%len(peaks)]
63		if back == 0 {
64			peak = *r.UsedPercent / max(now, 0.05)
65		}
66		points = append(points, quotaPoint{At: at, Percent: min(100, peak*progress)})
67	}
68	return points
69}
70
71func quotaModel(width, height int) QuotaModel {
72	m := NewQuotaModel(nil)
73	m.SetSize(width, height)
74	m, _ = m.Update(quotaLoadedMsg{Gen: m.Generation, At: quotaNow, Readings: quotaFixture()})
75	return m
76}
77
78// quotaWithTrails is a screen whose limits have all had their history read.
79func quotaWithTrails(width, height int) QuotaModel {
80	m := quotaModel(width, height)
81	for _, r := range m.readings {
82		if r.UsedPercent != nil {
83			m, _ = m.Update(quotaHistoryMsg{Gen: m.Generation, Key: quotaKey(r), Points: quotaTrailFixture(r)})
84		}
85	}
86	return m
87}
88
89func quotaShot(model func(width, height int) QuotaModel, keys ...tea.Msg) func(width, height int) string {
90	return func(width, height int) string {
91		m := model(width, height)
92		for _, key := range keys {
93			m, _ = m.Update(key)
94		}
95		return m.View()
96	}
97}
98
99func init() {
100	down := tea.KeyPressMsg{Code: tea.KeyDown}
101	registerShots("quota",
102		shotState{"list", quotaShot(quotaWithTrails)},
103		shotState{"warm", quotaShot(quotaWithTrails, down, down, down, down)},
104		shotState{"hot", quotaShot(quotaWithTrails, down, down, down, down, down)},
105		shotState{"stale", quotaShot(quotaWithTrails, down, down, down)},
106		shotState{"failed", quotaShot(quotaWithTrails, down, down)},
107		shotState{"history-loading", quotaShot(quotaModel)},
108		shotState{"filtering", quotaShot(quotaWithTrails, shotTyped("hyper")...)},
109		shotState{"no-match", quotaShot(quotaWithTrails, shotTyped("zzz")...)},
110		shotState{"loading", func(width, height int) string {
111			m := NewQuotaModel(nil)
112			m.SetSize(width, height)
113			return m.View()
114		}},
115		shotState{"empty", func(width, height int) string {
116			m := NewQuotaModel(nil)
117			m.SetSize(width, height)
118			m, _ = m.Update(quotaLoadedMsg{Gen: m.Generation, At: quotaNow})
119			return m.View()
120		}},
121		shotState{"load-failed", func(width, height int) string {
122			m := NewQuotaModel(nil)
123			m.SetSize(width, height)
124			m, _ = m.Update(quotaLoadedMsg{Gen: m.Generation, Err: errors.New("daemon connection unavailable")})
125			return m.View()
126		}},
127	)
128}