From 8012a376da2248b77f6c1579509f958f4351e0da Mon Sep 17 00:00:00 2001 From: merefield Date: Fri, 11 Sep 2026 13:24:27 +0100 Subject: [PATCH 1/4] FEAT: add reset inventory and expiry-aware redemption --- README.md | 40 +++++- internal/codex/client.go | 13 +- internal/codex/client_test.go | 11 +- internal/codex/reset_credits_test.go | 30 +++++ internal/codex/types.go | 13 +- internal/i18n/locales/da.json | 2 + internal/i18n/locales/de.json | 2 + internal/i18n/locales/en-GB.json | 2 + internal/i18n/locales/es.json | 2 + internal/i18n/locales/et.json | 2 + internal/i18n/locales/fi.json | 2 + internal/i18n/locales/fr.json | 2 + internal/i18n/locales/it.json | 2 + internal/i18n/locales/ja.json | 2 + internal/i18n/locales/nb.json | 2 + internal/i18n/locales/nl.json | 2 + internal/i18n/locales/pt-BR.json | 2 + internal/i18n/locales/pt-PT.json | 2 + internal/i18n/locales/ru.json | 2 + internal/i18n/locales/sv.json | 2 + internal/i18n/locales/tr.json | 2 + internal/i18n/locales/zh-Hans.json | 2 + internal/ui/english_snapshot_test.go | 4 +- internal/ui/model.go | 26 +++- internal/ui/preferences.go | 2 +- internal/ui/preferences_test.go | 2 +- internal/ui/quota_api_eq_test.go | 3 + internal/ui/quota_reset.go | 176 ++++++++++++++++++++++++++- internal/ui/reset_credits_test.go | 167 +++++++++++++++++++++++++ internal/ui/tabs.go | 8 +- internal/ui/tabs_test.go | 8 +- internal/ui/theme.go | 3 + internal/ui/view.go | 10 +- internal/ui/view_test.go | 12 +- intro-post.md | 2 +- main.go | 2 +- 36 files changed, 532 insertions(+), 34 deletions(-) create mode 100644 internal/codex/reset_credits_test.go create mode 100644 internal/ui/reset_credits_test.go diff --git a/README.md b/README.md index 1fbe8f6..45a63cb 100644 --- a/README.md +++ b/README.md @@ -195,7 +195,9 @@ English rendering baseline captured from v0.12.0. named in warning states and a celebratory fresh-reset signal at 0% usage. - A countdown to the next automatic refresh. - Confirmed redemption of available banked quota resets, normally offered only - when a displayed window is at least 80% consumed (configurable). + when a displayed window is at least 80% consumed (configurable), or a known + available reset expires in less than 72 hours. A dedicated Quota → Resets view + remains accessible below the usage threshold. - Account token history in a daily activity grid, weekly bars, or a cumulative graph, with a 6/12-month range and lifetime, peak-day, and streak summaries when supplied. This server-side history can lag live local telemetry. @@ -562,7 +564,7 @@ codexometer --codex /path/to/codex The responsive top rail below the account status selects Quota, Sessions, Usage, or Benchmark by mouse, `Tab`, or `Shift+Tab`. Quota adds a second rail for Bars, -Consumption Pace, Pie, and Fuel Tank; select these with the mouse or cycle them +Consumption Pace, Pie, Fuel Tank, and Resets; select these with the mouse or cycle them with `v`. Codexometer remembers the selected Quota view when you leave and return. Both rails condense automatically as the terminal narrows. The footer presents the remaining actions as clickable buttons, including View @@ -824,7 +826,7 @@ The default remains the original green hacker-terminal presentation. ## Views and quota presentations The top-level tabs are **Quota**, **Sessions**, **Usage**, and **Benchmark**. Within Quota, -choose one of these four views with its sub-tab or `v`: +choose one of these five views with its sub-tab or `v`: 1. **Bars** — chunky quota bars, with one full-width rate-limit window per row. 2. **Consumption Pace** — a signed horizontal scale comparing elapsed window @@ -838,6 +840,30 @@ choose one of these four views with its sub-tab or `v`: and whose dark segment shows consumed capacity, labelled from Empty to Full; one full-width tank appears per row. Its reset-cycle comparison also drains backward and aligns exactly with the tank's first and last inner cells. +5. **Resets** — available reset credits, grant dates, expiry dates and backend + descriptions. Expiring credits appear first, non-expiring credits last. + Scroll with Up/Down or Page Up/Page Down when necessary. + +The reset shortcut opens Resets and asks for confirmation before redeeming. +When individual credit details are supplied, Codexometer sends the ID of the +soonest-expiring available quota-reset credit it can identify. That ID remains +fixed through confirmation and any retry of an uncertain request; it never +silently switches credits. A credit that expires or disappears before a new +request is submitted requires a fresh confirmation. + +An amber `EXPIRING` shortcut appears on Quota views when a known available reset +has less than 72 hours left, even below `--reset-threshold`. This is an expiry +warning, not a recommendation to reset unused quota. Redemption still requires +fresh account data and explicit confirmation. The Resets view always permits +review regardless of usage percentage. + +Credit details are optional and may be capped by the backend. The view shows +how many of the available credits have usable details; earliest expiry means +**earliest known**, not a guarantee about undisclosed credits. If only a count +is available, expiry is unknown and the backend chooses the credit; its default +selection order is not guaranteed by the public protocol. A supplied null expiry +means “does not expire”, not “unknown”. Reset-credit expiry is separate from the +automatic quota-window reset date. ### Usage: account token history @@ -1903,12 +1929,14 @@ codexometer --codex ~/bin/codex On the Quota tab, `[ RESET // N ]` appears at the top right when a recent quota reading reports available banked resets, the account is verified, and -at least one displayed quota window is **80% consumed or higher**. +at least one displayed quota window is **80% consumed or higher**, or a known +available reset expires in **less than 72 hours**. In the dedicated **Resets** +view, the usage threshold does not apply. Set another threshold with `./codexometer --reset-threshold 60` (whole percentages from 0 to 100). For testing, `./codexometer --reset-threshold 0` bypasses the consumption threshold; an available credit and verified, fresh account data are still required. The default is 80 when the flag is omitted. -It moves below the main tabs on narrow terminals. Click once to reveal +It moves below the main tabs on narrow terminals. Click once to open Resets and reveal `[ CONFIRM RESET ]`, then click again within ten seconds to redeem one reset. `Esc` or changing tabs cancels confirmation. Redemption refreshes eligible quota windows and changes the weekly reset schedule; it does not add quota @@ -1920,7 +1948,7 @@ Codexometer uses the prevailing Codex login and the Older servers that do not report reset availability leave the button hidden. The button is disabled during submission, and quota/count data is fetched again afterward. If the result is uncertain, `[ RETRY RESET ]` repeats the -same attempt identifier, with another confirmation, to avoid consuming a second +same attempt identifier and selected credit ID, with another confirmation, to avoid consuming a second reset. Keep Codexometer open to retain that retry identifier. Check Codex's Usage page before attempting another reset after restarting the application. diff --git a/internal/codex/client.go b/internal/codex/client.go index 276c573..6d5c401 100644 --- a/internal/codex/client.go +++ b/internal/codex/client.go @@ -68,15 +68,20 @@ func (c Client) Fetch(ctx context.Context) (Snapshot, error) { type resetAttempt struct { key, account, outcome string + creditID string } // ConsumeReset uses the prevailing account, checking that it is the account // whose quota the user confirmed. Callers must reuse key after uncertain errors. func (c Client) ConsumeReset(ctx context.Context, key, account string) (string, error) { + return c.ConsumeResetCredit(ctx, key, account, "") +} + +func (c Client) ConsumeResetCredit(ctx context.Context, key, account, creditID string) (string, error) { if strings.TrimSpace(key) == "" || account == "" { return "", errors.New("reset requires an attempt key and verified account") } - attempt := &resetAttempt{key: key, account: account} + attempt := &resetAttempt{key: key, account: account, creditID: creditID} _, err := c.fetch(ctx, attempt, nil) return attempt.outcome, err } @@ -171,9 +176,13 @@ func (c Client) fetch(ctx context.Context, reset *resetAttempt, history *Account if accountFingerprint == "" || accountFingerprint != reset.account { return Snapshot{}, errors.New("Codex account changed or could not be verified; reset not submitted") } + params := map[string]any{"idempotencyKey": reset.key} + if reset.creditID != "" { + params["creditId"] = reset.creditID + } if err := encoder.Encode(map[string]any{ "method": "account/rateLimitResetCredit/consume", "id": 4, - "params": map[string]any{"idempotencyKey": reset.key}, + "params": params, }); err != nil { return Snapshot{}, fmt.Errorf("send Codex quota reset request: %w", err) } diff --git a/internal/codex/client_test.go b/internal/codex/client_test.go index 3be6c62..cad9d77 100644 --- a/internal/codex/client_test.go +++ b/internal/codex/client_test.go @@ -154,9 +154,10 @@ func runFakeAppServer() { continue } var params struct { - Key string `json:"idempotencyKey"` + Key string `json:"idempotencyKey"` + CreditID string `json:"creditId"` } - if json.Unmarshal(request.Params, ¶ms) != nil || params.Key != "test-attempt" { + if json.Unmarshal(request.Params, ¶ms) != nil || params.Key != "test-attempt" || params.CreditID != os.Getenv("CODEXOMETER_FAKE_RESET_CREDIT") { _ = encoder.Encode(map[string]any{"id": *request.ID, "error": map[string]any{"code": -32602, "message": "invalid attempt"}}) continue } @@ -235,6 +236,12 @@ func TestConsumeResetAccountBindingAndOutcomes(t *testing.T) { t.Fatalf("%s: %q %v", outcome, got, err) } } + t.Setenv("CODEXOMETER_FAKE_RESET_CREDIT", "earliest-credit") + t.Setenv("CODEXOMETER_FAKE_RESET_OUTCOME", "reset") + if got, err := c.ConsumeResetCredit(context.Background(), "test-attempt", snapshot.AccountFingerprint, "earliest-credit"); err != nil || got != "reset" { + t.Fatalf("targeted credit: %q %v", got, err) + } + t.Setenv("CODEXOMETER_FAKE_RESET_CREDIT", "") if _, err := c.ConsumeReset(context.Background(), "test-attempt", "different-account"); err == nil { t.Fatal("account mismatch accepted") } diff --git a/internal/codex/reset_credits_test.go b/internal/codex/reset_credits_test.go new file mode 100644 index 0000000..dca7251 --- /dev/null +++ b/internal/codex/reset_credits_test.go @@ -0,0 +1,30 @@ +package codex + +import ( + "encoding/json" + "testing" +) + +func TestDecodeResetCreditDetails(t *testing.T) { + var snapshot Snapshot + err := json.Unmarshal([]byte(`{"rateLimitResetCredits":{"availableCount":3,"credits":[{"id":"expiring","resetType":"codexRateLimits","status":"available","grantedAt":1700000000,"expiresAt":1800000000,"title":"Weekly reset","description":"Refresh eligible limits"},{"id":"forever","resetType":"codexRateLimits","status":"available","expiresAt":null}]}}`), &snapshot) + if err != nil { + t.Fatal(err) + } + summary := snapshot.RateLimitResetCredits + if summary == nil || summary.AvailableCount != 3 || len(summary.Credits) != 2 { + t.Fatalf("partial inventory: %#v", summary) + } + first := summary.Credits[0] + if first.ID != "expiring" || first.ExpiresAt == nil || *first.ExpiresAt != 1800000000 || first.GrantedAt != 1700000000 || first.Title != "Weekly reset" || first.Description == "" { + t.Fatalf("lost detail: %#v", first) + } + if summary.Credits[1].ExpiresAt != nil { + t.Fatal("invented non-expiring date") + } + var countOnly ResetCredits + err = json.Unmarshal([]byte(`{"availableCount":2}`), &countOnly) + if err != nil || countOnly.AvailableCount != 2 || countOnly.Credits != nil { + t.Fatal("count-only compatibility lost") + } +} diff --git a/internal/codex/types.go b/internal/codex/types.go index 6dd4cbb..42a3e27 100644 --- a/internal/codex/types.go +++ b/internal/codex/types.go @@ -47,7 +47,18 @@ type IndividualLimit struct { } type ResetCredits struct { - AvailableCount int `json:"availableCount"` + AvailableCount int `json:"availableCount"` + Credits []ResetCredit `json:"credits"` +} + +type ResetCredit struct { + ID string `json:"id"` + ResetType string `json:"resetType"` + Status string `json:"status"` + GrantedAt int64 `json:"grantedAt"` + ExpiresAt *int64 `json:"expiresAt"` + Title string `json:"title"` + Description string `json:"description"` } type Meter struct { diff --git a/internal/i18n/locales/da.json b/internal/i18n/locales/da.json index 35c0e2d..210fad5 100644 --- a/internal/i18n/locales/da.json +++ b/internal/i18n/locales/da.json @@ -1,4 +1,6 @@ { + "RESETS": "NULSTILLINGER", + "╭ RESETS ╮": "╭ NULSTILLINGER ╮", "APPROVAL — OPEN DETAIL →": "GODKENDELSE — ÅBN DETALJER →", "APPROVAL →": "GODKENDELSE →", "←/→ DETAIL": "←/→ DETALJER", diff --git a/internal/i18n/locales/de.json b/internal/i18n/locales/de.json index f82e87e..730e350 100644 --- a/internal/i18n/locales/de.json +++ b/internal/i18n/locales/de.json @@ -1,4 +1,6 @@ { + "RESETS": "ZURÜCKSETZUNGEN", + "╭ RESETS ╮": "╭ ZURÜCKSETZUNGEN ╮", "APPROVAL — OPEN DETAIL →": "FREIGABE — DETAILS ÖFFNEN →", "APPROVAL →": "FREIGABE →", "←/→ DETAIL": "←/→ DETAILS", diff --git a/internal/i18n/locales/en-GB.json b/internal/i18n/locales/en-GB.json index 3e324c2..411569e 100644 --- a/internal/i18n/locales/en-GB.json +++ b/internal/i18n/locales/en-GB.json @@ -1,4 +1,6 @@ { + "RESETS": "RESETS", + "╭ RESETS ╮": "╭ RESETS ╮", "APPROVAL — OPEN DETAIL →": "APPROVAL — OPEN DETAIL →", "APPROVAL →": "APPROVAL →", "←/→ DETAIL": "←/→ DETAIL", diff --git a/internal/i18n/locales/es.json b/internal/i18n/locales/es.json index ecd9447..6931e94 100644 --- a/internal/i18n/locales/es.json +++ b/internal/i18n/locales/es.json @@ -1,4 +1,6 @@ { + "RESETS": "REINICIOS", + "╭ RESETS ╮": "╭ REINICIOS ╮", "APPROVAL — OPEN DETAIL →": "APROBACIÓN — ABRIR DETALLES →", "APPROVAL →": "APROBACIÓN →", "←/→ DETAIL": "←/→ DETALLES", diff --git a/internal/i18n/locales/et.json b/internal/i18n/locales/et.json index ebc554d..38b3389 100644 --- a/internal/i18n/locales/et.json +++ b/internal/i18n/locales/et.json @@ -1,4 +1,6 @@ { + "RESETS": "LÄHTESTUSED", + "╭ RESETS ╮": "╭ LÄHTESTUSED ╮", "APPROVAL — OPEN DETAIL →": "KINNITAMINE — AVA ÜKSIKASJAD →", "APPROVAL →": "KINNITAMINE →", "←/→ DETAIL": "←/→ ÜKSIKASJAD", diff --git a/internal/i18n/locales/fi.json b/internal/i18n/locales/fi.json index c0b0ee4..06f40c7 100644 --- a/internal/i18n/locales/fi.json +++ b/internal/i18n/locales/fi.json @@ -1,4 +1,6 @@ { + "RESETS": "NOLLAUKSET", + "╭ RESETS ╮": "╭ NOLLAUKSET ╮", "APPROVAL — OPEN DETAIL →": "HYVÄKSYNTÄ — AVAA TIEDOT →", "APPROVAL →": "HYVÄKSYNTÄ →", "←/→ DETAIL": "←/→ TIEDOT", diff --git a/internal/i18n/locales/fr.json b/internal/i18n/locales/fr.json index 1872ba3..143f028 100644 --- a/internal/i18n/locales/fr.json +++ b/internal/i18n/locales/fr.json @@ -1,4 +1,6 @@ { + "RESETS": "RÉINITIALISATIONS", + "╭ RESETS ╮": "╭ RÉINITIALISATIONS ╮", "APPROVAL — OPEN DETAIL →": "APPROBATION — OUVRIR LES DÉTAILS →", "APPROVAL →": "APPROBATION →", "←/→ DETAIL": "←/→ DÉTAILS", diff --git a/internal/i18n/locales/it.json b/internal/i18n/locales/it.json index bfc3f25..66b6cac 100644 --- a/internal/i18n/locales/it.json +++ b/internal/i18n/locales/it.json @@ -1,4 +1,6 @@ { + "RESETS": "RIPRISTINI", + "╭ RESETS ╮": "╭ RIPRISTINI ╮", "APPROVAL — OPEN DETAIL →": "APPROVAZIONE — APRI DETTAGLI →", "APPROVAL →": "APPROVAZIONE →", "←/→ DETAIL": "←/→ DETTAGLI", diff --git a/internal/i18n/locales/ja.json b/internal/i18n/locales/ja.json index ee02e07..4d1d100 100644 --- a/internal/i18n/locales/ja.json +++ b/internal/i18n/locales/ja.json @@ -1,4 +1,6 @@ { + "RESETS": "リセット", + "╭ RESETS ╮": "╭ リセット ╮", "APPROVAL — OPEN DETAIL →": "承認 — 詳細を開く →", "APPROVAL →": "承認 →", "←/→ DETAIL": "←/→ 詳細", diff --git a/internal/i18n/locales/nb.json b/internal/i18n/locales/nb.json index d4cc2e7..b053e4e 100644 --- a/internal/i18n/locales/nb.json +++ b/internal/i18n/locales/nb.json @@ -1,4 +1,6 @@ { + "RESETS": "NULLSTILLINGER", + "╭ RESETS ╮": "╭ NULLSTILLINGER ╮", "APPROVAL — OPEN DETAIL →": "GODKJENNING — ÅPNE DETALJER →", "APPROVAL →": "GODKJENNING →", "←/→ DETAIL": "←/→ DETALJER", diff --git a/internal/i18n/locales/nl.json b/internal/i18n/locales/nl.json index b06f8b0..73071c7 100644 --- a/internal/i18n/locales/nl.json +++ b/internal/i18n/locales/nl.json @@ -1,4 +1,6 @@ { + "RESETS": "RESETS", + "╭ RESETS ╮": "╭ RESETS ╮", "APPROVAL — OPEN DETAIL →": "GOEDKEURING — OPEN DETAILS →", "APPROVAL →": "GOEDKEURING →", "←/→ DETAIL": "←/→ DETAIL", diff --git a/internal/i18n/locales/pt-BR.json b/internal/i18n/locales/pt-BR.json index 3c92697..a5a3cf9 100644 --- a/internal/i18n/locales/pt-BR.json +++ b/internal/i18n/locales/pt-BR.json @@ -1,4 +1,6 @@ { + "RESETS": "REDEFINIÇÕES", + "╭ RESETS ╮": "╭ REDEFINIÇÕES ╮", "APPROVAL — OPEN DETAIL →": "APROVAÇÃO — ABRIR DETALHES →", "APPROVAL →": "APROVAÇÃO →", "←/→ DETAIL": "←/→ DETALHES", diff --git a/internal/i18n/locales/pt-PT.json b/internal/i18n/locales/pt-PT.json index e257cc6..18f4db2 100644 --- a/internal/i18n/locales/pt-PT.json +++ b/internal/i18n/locales/pt-PT.json @@ -1,4 +1,6 @@ { + "RESETS": "REPOSIÇÕES", + "╭ RESETS ╮": "╭ REPOSIÇÕES ╮", "APPROVAL — OPEN DETAIL →": "APROVAÇÃO — ABRIR DETALHES →", "APPROVAL →": "APROVAÇÃO →", "←/→ DETAIL": "←/→ DETALHES", diff --git a/internal/i18n/locales/ru.json b/internal/i18n/locales/ru.json index fad4cad..82534cd 100644 --- a/internal/i18n/locales/ru.json +++ b/internal/i18n/locales/ru.json @@ -1,4 +1,6 @@ { + "RESETS": "СБРОСЫ", + "╭ RESETS ╮": "╭ СБРОСЫ ╮", "APPROVAL — OPEN DETAIL →": "ОДОБРЕНИЕ — ОТКРЫТЬ ПОДРОБНОСТИ →", "APPROVAL →": "ОДОБРЕНИЕ →", "←/→ DETAIL": "←/→ ПОДРОБНОСТИ", diff --git a/internal/i18n/locales/sv.json b/internal/i18n/locales/sv.json index 6446be9..82e45d8 100644 --- a/internal/i18n/locales/sv.json +++ b/internal/i18n/locales/sv.json @@ -1,4 +1,6 @@ { + "RESETS": "ÅTERSTÄLLNINGAR", + "╭ RESETS ╮": "╭ ÅTERSTÄLLNINGAR ╮", "APPROVAL — OPEN DETAIL →": "GODKÄNNANDE — ÖPPNA DETALJER →", "APPROVAL →": "GODKÄNNANDE →", "←/→ DETAIL": "←/→ DETALJER", diff --git a/internal/i18n/locales/tr.json b/internal/i18n/locales/tr.json index 70daa7c..85d7207 100644 --- a/internal/i18n/locales/tr.json +++ b/internal/i18n/locales/tr.json @@ -1,4 +1,6 @@ { + "RESETS": "SIFIRLAMALAR", + "╭ RESETS ╮": "╭ SIFIRLAMALAR ╮", "APPROVAL — OPEN DETAIL →": "ONAY — AYRINTILARI AÇ →", "APPROVAL →": "ONAY →", "←/→ DETAIL": "←/→ AYRINTILAR", diff --git a/internal/i18n/locales/zh-Hans.json b/internal/i18n/locales/zh-Hans.json index 38864e3..6c5a6ad 100644 --- a/internal/i18n/locales/zh-Hans.json +++ b/internal/i18n/locales/zh-Hans.json @@ -1,4 +1,6 @@ { + "RESETS": "重置", + "╭ RESETS ╮": "╭ 重置 ╮", "APPROVAL — OPEN DETAIL →": "审批 — 打开详情 →", "APPROVAL →": "审批 →", "←/→ DETAIL": "←/→ 详情", diff --git a/internal/ui/english_snapshot_test.go b/internal/ui/english_snapshot_test.go index a320e78..1528d67 100644 --- a/internal/ui/english_snapshot_test.go +++ b/internal/ui/english_snapshot_test.go @@ -31,9 +31,9 @@ func TestEnglishPresentationSnapshot(t *testing.T) { } } got := fmt.Sprintf("%x", hash.Sum(nil)) - // Baseline intentionally updated for Sessions / SESSION TOTALS branding; + // Baseline intentionally updated for the additional Quota / Resets view; // all themes, views and three terminal sizes are covered. - const want = "3813648a9bb9fcb16559f4eea5f1375f0e6fdc611d0c6d1191545966b7c0474d" + const want = "9f465544c26ed1b8f652c32d4a58c75c6981322a04ba95b7a045f75fbf092f49" if got != want { t.Fatalf("English presentation changed: got %s, want %s", got, want) } diff --git a/internal/ui/model.go b/internal/ui/model.go index 1b1d25a..595c934 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -65,6 +65,8 @@ type Model struct { resetHovered bool resetBusy bool resetKey, resetAccount, resetNotice string + resetCreditID string + resetScroll int resetConfirmUntil time.Time resetRevision uint64 fetcher Fetcher @@ -475,6 +477,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.resetNotice = "Reset unconfirmed: " + message.err.Error() + ". Retry uses the same attempt." } else { m.resetKey, m.resetAccount = "", "" + m.resetCreditID = "" switch message.outcome { case "reset", "alreadyRedeemed": m.resetNotice = "Quota reset. Refreshing limits…" @@ -497,6 +500,25 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { return next, cmd } } + if m.meterView == viewResets { + step := 0 + switch strings.ToLower(message.String()) { + case "up": + step = -1 + case "down": + step = 1 + case "pgup": + step = -max(m.dashboardLayout().meterHeight-2, 1) + case "pgdown": + step = max(m.dashboardLayout().meterHeight-2, 1) + } + if step != 0 { + g := m.dashboardLayout() + limit := max(len(m.resetDetailLines(max(g.contentWidth-4, 1), paletteFor(m.theme)))-max(g.meterHeight-2, 1), 0) + m.resetScroll = min(max(m.resetScroll+step, 0), limit) + return m, nil + } + } if m.meterView == viewUsage { if action, ok := historyKey(strings.ToLower(message.String())); ok { m.activateHistory(action) @@ -1368,7 +1390,7 @@ func (m Model) dashboardLayout() dashboardGeometry { extraHeight += framedErrorHeight } meters := m.snapshot.Meters() - if len(meters) == 0 && m.meterView != viewUsage { + if len(meters) == 0 && m.meterView != viewUsage && m.meterView != viewResets { extraHeight += framedErrorHeight } if (m.meterView == viewBars || m.meterView == viewConsumptionPace || m.meterView == viewFuel) && len(meters) > 0 { @@ -1390,7 +1412,7 @@ func (m Model) dashboardLayout() dashboardGeometry { meterY := tabsY + tabsHeight + extraHeight meterHeight := max(contentHeight-headerHeight-statusHeight-tabsHeight-extraHeight-footerHeight, 1) footerY := meterY - if m.meterView == viewUsage || m.meterView == viewMonitor || m.meterView == viewBenchmark || len(m.snapshot.Meters()) > 0 { + if m.meterView == viewUsage || m.meterView == viewResets || m.meterView == viewMonitor || m.meterView == viewBenchmark || len(m.snapshot.Meters()) > 0 { footerY += meterHeight } return dashboardGeometry{ diff --git a/internal/ui/preferences.go b/internal/ui/preferences.go index 1a7ea2d..6d86780 100644 --- a/internal/ui/preferences.go +++ b/internal/ui/preferences.go @@ -135,7 +135,7 @@ func reverseThemePreferences(values map[themeID]string) map[string]themeID { } var quotaViewPreferenceNames = map[meterViewID]string{ - viewBars: "bars", viewPie: "pie", viewConsumptionPace: "consumption-pace", viewFuel: "fuel-tank", + viewBars: "bars", viewPie: "pie", viewConsumptionPace: "consumption-pace", viewFuel: "fuel-tank", viewResets: "resets", } var quotaViewPreferenceIDs = reverseViewPreferences(quotaViewPreferenceNames) diff --git a/internal/ui/preferences_test.go b/internal/ui/preferences_test.go index 8a4e575..044d2cc 100644 --- a/internal/ui/preferences_test.go +++ b/internal/ui/preferences_test.go @@ -46,7 +46,7 @@ func TestPreferencesRestoreAndPersistPresentationChoices(t *testing.T) { t.Fatalf("preference saves = %d, want one for each presentation change", len(store.saves)) } last := store.saves[len(store.saves)-1] - if last.Theme != "hacker" || last.QuotaView != "bars" || last.BenchmarkFilter != "pass" || last.BenchmarkRank != "speed" { + if last.Theme != "hacker" || last.QuotaView != "resets" || last.BenchmarkFilter != "pass" || last.BenchmarkRank != "speed" { t.Fatalf("persisted preferences = %#v", last) } } diff --git a/internal/ui/quota_api_eq_test.go b/internal/ui/quota_api_eq_test.go index 8f5e981..5fd44fc 100644 --- a/internal/ui/quota_api_eq_test.go +++ b/internal/ui/quota_api_eq_test.go @@ -339,6 +339,9 @@ func TestEveryQuotaViewDisplaysResponsiveAPIEqReadout(t *testing.T) { ObservedAtUnix: now.Unix(), PricingRetrievedOn: codex.StandardAPIPricingRetrievedOn, }} for _, view := range quotaViewOrder { + if view == viewResets { + continue + } // Credit inventory has no quota-price readout. model.meterView, model.quotaMeterView = view, view rendered := ansi.Strip(model.render()) if !strings.Contains(rendered, "API-EQ") || !strings.Contains(rendered, "100%") { diff --git a/internal/ui/quota_reset.go b/internal/ui/quota_reset.go index be0bcc1..f473dc3 100644 --- a/internal/ui/quota_reset.go +++ b/internal/ui/quota_reset.go @@ -4,12 +4,15 @@ import ( "context" "crypto/rand" "fmt" + "slices" + "strings" "time" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" + "github.com/merefield/codexometer/internal/codex" "github.com/merefield/codexometer/internal/i18n" ) @@ -17,12 +20,59 @@ type resetConsumer interface { ConsumeReset(context.Context, string, string) (string, error) } +type resetCreditConsumer interface { + ConsumeResetCredit(context.Context, string, string, string) (string, error) +} + +const resetExpiryWarning = 72 * time.Hour + +// Details may be absent or capped. Never infer expiry from the quota window. +func (m Model) availableResetCredits() []codex.ResetCredit { + summary := m.snapshot.RateLimitResetCredits + if summary == nil || summary.AvailableCount <= 0 { + return nil + } + credits := make([]codex.ResetCredit, 0, len(summary.Credits)) + for _, credit := range summary.Credits { + if credit.ID != "" && credit.Status == "available" && credit.ResetType == "codexRateLimits" && (credit.ExpiresAt == nil || *credit.ExpiresAt > time.Now().Unix()) { + credits = append(credits, credit) + } + } + slices.SortStableFunc(credits, func(a, b codex.ResetCredit) int { + if a.ExpiresAt == nil && b.ExpiresAt == nil { + return strings.Compare(a.ID, b.ID) + } + if a.ExpiresAt == nil { + return 1 + } + if b.ExpiresAt == nil { + return -1 + } + if *a.ExpiresAt < *b.ExpiresAt { + return -1 + } + if *a.ExpiresAt > *b.ExpiresAt { + return 1 + } + return strings.Compare(a.ID, b.ID) + }) + return credits[:min(len(credits), summary.AvailableCount)] +} + +func (m Model) resetExpiringSoon() bool { + credits := m.availableResetCredits() + return len(credits) > 0 && credits[0].ExpiresAt != nil && time.Until(time.Unix(*credits[0].ExpiresAt, 0)) < resetExpiryWarning +} + // SetResetThreshold sets the consumed percentage required to offer a reset. // Zero bypasses consumption checks, but still requires an available credit. func (m *Model) SetResetThreshold(percent int) { m.resetThreshold = min(max(percent, 0), 100) } func (m Model) renderResetButton(label string, colors palette) string { style := colors.label() + if m.resetExpiringSoon() { + style = style.Foreground(colors.warning) + } if m.resetHovered && !m.resetBusy { style = style.Foreground(colors.background).Background(colors.primary) } @@ -47,6 +97,14 @@ func (m Model) resetNoticeHeight(width int) int { } func (m Model) resetLabel() string { + width := m.width + if width == 0 { + width = 80 + } + return ansi.Truncate(m.resetFullLabel(), max(width-4, 1), "") +} + +func (m Model) resetFullLabel() string { if !m.meterView.isQuota() { return "" } @@ -65,7 +123,7 @@ func (m Model) resetLabel() string { m.snapshot.FetchedAt.IsZero() || time.Since(m.snapshot.FetchedAt) > 2*m.refreshEvery { return "" } - if m.resetThreshold > 0 { + if m.resetThreshold > 0 && m.meterView != viewResets && !m.resetExpiringSoon() { eligible := false for _, meter := range m.snapshot.Meters() { if meter.Window.UsedPercent >= m.resetThreshold { @@ -77,6 +135,12 @@ func (m Model) resetLabel() string { return "" } } + if m.resetExpiringSoon() { + if m.width > 0 && m.width < 40 { + return fmt.Sprintf("[ RESET // %d ! ]", m.snapshot.RateLimitResetCredits.AvailableCount) + } + return fmt.Sprintf("[ RESET // %d // EXPIRING ]", m.snapshot.RateLimitResetCredits.AvailableCount) + } return i18n.Format("[ RESET // %d ]", m.snapshot.RateLimitResetCredits.AvailableCount) } @@ -117,9 +181,28 @@ func (m Model) pressQuotaReset() (tea.Model, tea.Cmd) { if m.resetConfirmUntil.IsZero() || time.Now().After(m.resetConfirmUntil) { if m.resetKey == "" { m.resetAccount = m.snapshot.AccountFingerprint + m.resetCreditID = "" + if credits := m.availableResetCredits(); len(credits) > 0 { + if _, ok := m.fetcher.(resetCreditConsumer); ok { + m.resetCreditID = credits[0].ID + } + } } + m.meterView, m.quotaMeterView = viewResets, viewResets + m.resetScroll = 0 + m.persistPreferences() m.resetConfirmUntil = time.Now().Add(10 * time.Second) m.resetNotice = i18n.Text("Use one reset? Refreshes eligible quota and changes the weekly reset schedule. Click CONFIRM; Esc cancels.") + if m.resetCreditID == "" { + m.resetNotice += " Expiry order unavailable; backend chooses the credit." + } else { + for _, credit := range m.availableResetCredits() { + if credit.ID == m.resetCreditID { + m.resetNotice += " Selected: " + resetCreditTitle(credit) + " — " + resetCreditExpiry(credit) + "." + break + } + } + } return m, nil } m.resetConfirmUntil = time.Time{} @@ -128,6 +211,27 @@ func (m Model) pressQuotaReset() (tea.Model, tea.Cmd) { return m, nil } if m.resetKey == "" { + if m.loading || m.err != nil || m.snapshot.FetchedAt.IsZero() || time.Since(m.snapshot.FetchedAt) > 2*m.refreshEvery { + m.resetNotice = "Quota data is not fresh; refresh and confirm again. No reset submitted." + return m, nil + } + if m.snapshot.RateLimitResetCredits == nil || m.snapshot.RateLimitResetCredits.AvailableCount <= 0 { + m.resetNotice = "No reset credits available; no reset submitted." + return m, nil + } + if m.resetCreditID != "" { + found := false + for _, credit := range m.availableResetCredits() { + if credit.ID == m.resetCreditID { + found = true + break + } + } + if !found { + m.resetNotice = "Selected reset is no longer available; review and confirm again." + return m, nil + } + } var id [16]byte if _, err := rand.Read(id[:]); err != nil { m.resetNotice = "Could not generate a reset request ID; no reset submitted: " + err.Error() @@ -140,9 +244,77 @@ func (m Model) pressQuotaReset() (tea.Model, tea.Cmd) { m.resetBusy = true m.resetRevision++ m.resetNotice = "Resetting quota…" - key, account := m.resetKey, m.resetAccount + key, account, creditID := m.resetKey, m.resetAccount, m.resetCreditID return m, func() tea.Msg { + if creditID != "" { + if specific, ok := consumer.(resetCreditConsumer); ok { + outcome, err := specific.ConsumeResetCredit(context.Background(), key, account, creditID) + return quotaResetResult{outcome, err} + } + return quotaResetResult{err: fmt.Errorf("selected-credit redemption is unavailable; no reset submitted")} + } outcome, err := consumer.ConsumeReset(context.Background(), key, account) return quotaResetResult{outcome, err} } } + +func resetCreditTitle(c codex.ResetCredit) string { + if strings.TrimSpace(c.Title) != "" { + return codex.SanitizeSessionContext(c.Title) + } + return "Full reset" +} + +func resetCreditExpiry(c codex.ResetCredit) string { + if c.ExpiresAt == nil { + return "Does not expire" + } + return "Expires " + time.Unix(*c.ExpiresAt, 0).Local().Format("02 Jan 2006 15:04 MST") +} + +func (m Model) resetDetailLines(width int, colors palette) (result []string) { + defer func() { result = strings.Split(ansi.Hardwrap(strings.Join(result, "\n"), max(width, 1), true), "\n") }() + summary := m.snapshot.RateLimitResetCredits + if summary == nil { + return []string{"Reset information unavailable."} + } + lines := []string{fmt.Sprintf("AVAILABLE // %d", summary.AvailableCount)} + credits := m.availableResetCredits() + if m.resetExpiringSoon() { + lines = append(lines, colors.label().Foreground(colors.warning).Render("EXPIRING SOON // within 72 hours")) + } + if summary.AvailableCount == 0 { + return append(lines, "No resets available.") + } + if len(credits) == 0 { + return append(lines, "Expiry details unavailable. Backend selects the next credit.") + } + lines = append(lines, fmt.Sprintf("Showing %d of %d available resets. Earliest known expiry first.", len(credits), summary.AvailableCount)) + for index, credit := range credits { + title := fmt.Sprintf("%d // %s", index+1, resetCreditTitle(credit)) + if credit.ID == m.resetCreditID && (!m.resetConfirmUntil.IsZero() || m.resetKey != "") { + title += " // SELECTED" + } else if index == 0 { + title += " // NEXT" + } + lines = append(lines, "", colors.label().Render(title), resetCreditExpiry(credit)) + if credit.GrantedAt > 0 { + lines = append(lines, "Granted "+time.Unix(credit.GrantedAt, 0).Local().Format("02 Jan 2006 15:04 MST")) + } + if credit.Description != "" { + lines = append(lines, codex.SanitizeSessionContext(credit.Description)) + } + } + return lines +} + +func (m Model) renderResets(width, height int, colors palette) string { + lines := m.resetDetailLines(max(width-4, 1), colors) + rows := max(height-2, 1) + start := min(m.resetScroll, max(len(lines)-rows, 0)) + title := i18n.Text("RESETS") + if len(lines) > rows { + title += " // ↑↓ PgUp/PgDn" + } + return lipgloss.NewStyle().MaxWidth(width).MaxHeight(height).Render(frameSized(width, max(height-2, 1), title, strings.Join(lines[start:min(start+rows, len(lines))], "\n"), colors.primary, colors)) +} diff --git a/internal/ui/reset_credits_test.go b/internal/ui/reset_credits_test.go new file mode 100644 index 0000000..eb8f9c4 --- /dev/null +++ b/internal/ui/reset_credits_test.go @@ -0,0 +1,167 @@ +package ui + +import ( + "context" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/merefield/codexometer/internal/codex" +) + +type specificResetFake struct { + resetFake + ids []string +} + +func (f *specificResetFake) ConsumeResetCredit(ctx context.Context, key, account, id string) (string, error) { + f.ids = append(f.ids, id) + return f.ConsumeReset(ctx, key, account) +} + +func credit(id string, after time.Duration) codex.ResetCredit { + expiry := time.Now().Add(after).Unix() + return codex.ResetCredit{ID: id, Status: "available", ResetType: "codexRateLimits", ExpiresAt: &expiry, Title: id} +} + +func TestResetCreditOrderingAndWarning(t *testing.T) { + m, _ := resetModel() + m.snapshot.RateLimits.Primary.UsedPercent = 1 + m.snapshot.RateLimitResetCredits.AvailableCount = 8 + noExpiry := credit("forever", time.Hour) + noExpiry.ExpiresAt = nil + redeemed := credit("redeemed", time.Minute) + redeemed.Status = "redeemed" + unknown := credit("unknown", time.Minute) + unknown.ResetType = "unknown" + m.snapshot.RateLimitResetCredits.Credits = []codex.ResetCredit{noExpiry, credit("later", 80*time.Hour), credit("first", time.Hour), credit("expired", -time.Hour), redeemed, unknown} + got := m.availableResetCredits() + if len(got) != 3 || got[0].ID != "first" || got[2].ID != "forever" { + t.Fatalf("order: %#v", got) + } + if !m.resetExpiringSoon() || !strings.Contains(m.resetLabel(), "EXPIRING") { + t.Fatal("expiry did not bypass threshold") + } + m.snapshot.RateLimitResetCredits.Credits = []codex.ResetCredit{credit("later", 73*time.Hour)} + if m.resetLabel() != "" { + t.Fatal("warned too early") + } + m.meterView = viewResets + if m.resetLabel() == "" { + t.Fatal("resets view inaccessible below threshold") + } + m.snapshot.FetchedAt = time.Now().Add(-3 * time.Minute) + if m.resetLabel() != "" { + t.Fatal("stale source allowed redemption") + } +} + +func TestResetSelectionBoundToConfirmationAndRetry(t *testing.T) { + m, _ := resetModel() + f := &specificResetFake{} + m.fetcher = f + m.snapshot.RateLimitResetCredits.Credits = []codex.ResetCredit{credit("later", 24*time.Hour), credit("first", time.Hour)} + u, cmd := m.pressQuotaReset() + m = u.(Model) + if cmd != nil || m.meterView != viewResets || m.resetCreditID != "first" { + t.Fatal("did not open and arm earliest credit") + } + // A newer snapshot must not silently replace the already-confirmed choice. + m.snapshot.RateLimitResetCredits.Credits = append(m.snapshot.RateLimitResetCredits.Credits, credit("new-first", time.Minute)) + m.snapshot.RateLimitResetCredits.AvailableCount = 3 + u, cmd = m.pressQuotaReset() + m = u.(Model) + if cmd == nil { + t.Fatal("confirmation did not submit") + } + _ = cmd() + if len(f.ids) != 1 || f.ids[0] != "first" { + t.Fatalf("selected %v", f.ids) + } + key := m.resetKey + m.resetBusy = false // Simulate an uncertain response: preserve the attempt. + m.snapshot.RateLimitResetCredits.Credits = nil + u, _ = m.pressQuotaReset() + m = u.(Model) + u, cmd = m.pressQuotaReset() + m = u.(Model) + if cmd == nil { + t.Fatal("retry unavailable") + } + _ = cmd() + if m.resetKey != key || len(f.ids) != 2 || f.ids[1] != "first" { + t.Fatal("retry changed credit or key") + } +} + +func TestResetDisappearingCreditRequiresNewConfirmation(t *testing.T) { + m, _ := resetModel() + m.fetcher = &specificResetFake{} + m.snapshot.RateLimitResetCredits.Credits = []codex.ResetCredit{credit("first", time.Hour)} + u, _ := m.pressQuotaReset() + m = u.(Model) + m.snapshot.RateLimitResetCredits.Credits = []codex.ResetCredit{credit("replacement", time.Hour)} + u, cmd := m.pressQuotaReset() + m = u.(Model) + if cmd != nil || m.resetKey != "" || !strings.Contains(m.resetNotice, "no longer available") { + t.Fatal("silently substituted credit") + } +} + +func TestResetConfirmationRejectsStaleData(t *testing.T) { + m, _ := resetModel() + u, _ := m.pressQuotaReset() + m = u.(Model) + m.snapshot.FetchedAt = time.Now().Add(-3 * time.Minute) + u, cmd := m.pressQuotaReset() + m = u.(Model) + if cmd != nil || m.resetKey != "" || !strings.Contains(m.resetNotice, "not fresh") { + t.Fatal("submitted with stale data") + } +} + +func TestResetDetailsMissingAndPartial(t *testing.T) { + m, _ := resetModel() + colors := paletteFor(m.theme) + if !strings.Contains(strings.Join(m.resetDetailLines(100, colors), "\n"), "Expiry details unavailable") { + t.Fatal("missing details presented as no resets") + } + m.snapshot.RateLimitResetCredits.Credits = []codex.ResetCredit{credit("one", 24*time.Hour)} + lines := strings.Join(m.resetDetailLines(100, colors), "\n") + if !strings.Contains(lines, "Showing 1 of 2") || !strings.Contains(lines, "Expires") { + t.Fatal("missing partial disclosure") + } +} + +func TestResetsResponsiveGeometryAndScroll(t *testing.T) { + for _, width := range []int{28, 40, 80, 140} { + m, _ := resetModel() + m.width, m.height, m.meterView = width, 24, viewResets + m.snapshot.RateLimitResetCredits.AvailableCount = 12 + for range 12 { + m.snapshot.RateLimitResetCredits.Credits = append(m.snapshot.RateLimitResetCredits.Credits, credit("Reset", time.Hour)) + } + g := m.dashboardLayout() + rendered := m.renderResets(g.contentWidth, g.meterHeight, paletteFor(m.theme)) + if lipgloss.Width(rendered) > g.contentWidth || lipgloss.Height(rendered) != g.meterHeight { + t.Fatalf("%d: wrong dimensions", width) + } + row := g.tabsY + if m.resetOwnRow(g.contentWidth) { + row++ + } + label := m.resetLabel() + for x := 2 + g.contentWidth - lipgloss.Width(label); x < 2+g.contentWidth; x++ { + if !m.resetAt(x, row) { + t.Fatalf("%d missed reset cell %d,%d", width, x, row) + } + } + u, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyPgDown}) + m = u.(Model) + if m.resetScroll == 0 { + t.Fatal("page down did not scroll") + } + } +} diff --git a/internal/ui/tabs.go b/internal/ui/tabs.go index a69e748..ee0fe62 100644 --- a/internal/ui/tabs.go +++ b/internal/ui/tabs.go @@ -97,10 +97,10 @@ func mainTabLayout(width int, showMonitorLight bool) ([]mainTab, string) { func quotaViewTabLayout(width int) ([]viewTab, string) { labels, separator := responsiveTabLabels(width, [][]string{ - {i18n.Text("╭ BARS ╮"), i18n.Text("╭ CONSUMPTION PACE ╮"), i18n.Text("╭ PIE ╮"), i18n.Text("╭ FUEL TANK ╮")}, - {"╭BAR╮", "╭PACE╮", "╭PIE╮", "╭FUEL╮"}, - {"[B]", "[C]", "[P]", "[F]"}, - {"B", "C", "P", "F"}, + {i18n.Text("╭ BARS ╮"), i18n.Text("╭ CONSUMPTION PACE ╮"), i18n.Text("╭ PIE ╮"), i18n.Text("╭ FUEL TANK ╮"), i18n.Text("╭ RESETS ╮")}, + {"╭BAR╮", "╭PACE╮", "╭PIE╮", "╭FUEL╮", "╭RST╮"}, + {"[B]", "[C]", "[P]", "[F]", "[R]"}, + {"B", "C", "P", "F", "R"}, }) tabs := make([]viewTab, 0, len(quotaViewOrder)) diff --git a/internal/ui/tabs_test.go b/internal/ui/tabs_test.go index 34df455..bcbfa7d 100644 --- a/internal/ui/tabs_test.go +++ b/internal/ui/tabs_test.go @@ -64,9 +64,9 @@ func TestQuotaViewTabsChooseResponsiveLabels(t *testing.T) { want string }{ {width: 100, want: "CONSUMPTION PACE"}, - {width: 28, want: "PACE"}, - {width: 12, want: "[C]"}, - {width: 4, want: "C"}, + {width: 34, want: "PACE"}, + {width: 15, want: "[C]"}, + {width: 5, want: "C"}, } { t.Run(test.want, func(t *testing.T) { tabs, _ := quotaViewTabLayout(test.width) @@ -199,7 +199,7 @@ func TestQuotaStyleIsRememberedAcrossMainTabNavigation(t *testing.T) { func TestVSelectsQuotaViewAndMonitorShortcutsStayScoped(t *testing.T) { model := Model{meterView: viewBars} - for _, want := range []meterViewID{viewConsumptionPace, viewPie, viewFuel, viewBars} { + for _, want := range []meterViewID{viewConsumptionPace, viewPie, viewFuel, viewResets, viewBars} { updated, command := model.Update(key('v')) model = updated.(Model) if command == nil || model.meterView != want || model.quotaMeterView != want || model.flashedButton != footerButtonView { diff --git a/internal/ui/theme.go b/internal/ui/theme.go index 3f1a51d..d544cf0 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -33,6 +33,7 @@ const ( viewFuel viewBenchmark viewUsage + viewResets viewCount ) @@ -41,6 +42,7 @@ var quotaViewOrder = [...]meterViewID{ viewConsumptionPace, viewPie, viewFuel, + viewResets, } func (s meterViewID) isQuota() bool { @@ -70,6 +72,7 @@ func (s meterViewID) name() string { i18n.Text("FUEL TANK"), i18n.Text("BENCHMARK"), i18n.Text("USAGE"), + i18n.Text("RESETS"), }[s] } diff --git a/internal/ui/view.go b/internal/ui/view.go index 51abaf6..ac7771e 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -52,15 +52,17 @@ func (m Model) render() string { parts = append(parts, m.renderResetNotice(contentWidth)) } meters := m.snapshot.Meters() - if m.meterView.isQuota() { + if m.meterView.isQuota() && m.meterView != viewResets { meters = m.quotaMetersWithInsights(contentWidth) } - if len(meters) == 0 && m.meterView != viewUsage { + if len(meters) == 0 && m.meterView != viewUsage && m.meterView != viewResets { emptyView := renderError(contentWidth, fmt.Errorf("no quota windows returned"), colors) parts = append(parts, emptyView) } footer := m.renderFooter(contentWidth, colors) - if m.meterView == viewUsage { + if m.meterView == viewResets { + parts = append(parts, m.renderResets(contentWidth, layout.meterHeight, colors)) + } else if m.meterView == viewUsage { parts = append(parts, m.renderHistory(contentWidth, layout.meterHeight, colors)) } else if m.meterView == viewMonitor { parts = append(parts, m.renderMonitorArea(contentWidth, layout.meterHeight, colors).view) @@ -179,7 +181,7 @@ func (m Model) renderFooter(width int, colors palette) string { status = joinRight(status, colors.dimmed().Render(hint), width) } } - if m.meterView == viewBenchmark || m.meterView.isQuota() { + if m.meterView == viewBenchmark || (m.meterView.isQuota() && m.meterView != viewResets) { status = renderPricingFooter(status, width, colors) } buttons, separator := footerButtonLayoutWithTheme(width, colors.name, m.meterView.isQuota()) diff --git a/internal/ui/view_test.go b/internal/ui/view_test.go index a46a6c7..12280c6 100644 --- a/internal/ui/view_test.go +++ b/internal/ui/view_test.go @@ -39,6 +39,10 @@ func TestViewRendersEveryThemeAndViewWithinStandardTerminal(t *testing.T) { if !strings.Contains(output, "ALGORITHM TRIAL") || !strings.Contains(output, "RESULT MATRIX") { t.Errorf("benchmark components missing for theme %d", theme) } + } else if view == viewResets { + if !strings.Contains(output, "AVAILABLE") { + t.Error("reset summary missing") + } } else if view == viewUsage { if !strings.Contains(output, "LIFETIME") { t.Error("usage summary missing") @@ -186,6 +190,9 @@ func TestPricingFooterCentersProvenanceOnEveryPricedView(t *testing.T) { model := Model{nextRefresh: time.Now().Add(time.Minute)} pricedViews := append([]meterViewID{viewBenchmark}, quotaViewOrder[:]...) for _, view := range pricedViews { + if view == viewResets { + continue + } model.meterView = view raw := model.renderFooter(100, colors) if !strings.Contains(raw, codex.StandardAPIPricingSourceURL) { @@ -205,7 +212,7 @@ func TestPricingFooterCentersProvenanceOnEveryPricedView(t *testing.T) { } } - for _, view := range []meterViewID{viewMonitor} { + for _, view := range []meterViewID{viewMonitor, viewUsage, viewResets} { model.meterView = view footer := model.renderFooter(100, colors) if strings.Contains(footer, codex.StandardAPIPricingSourceURL) || strings.Contains(ansi.Strip(footer), codex.StandardAPIPricingRetrievedOn) { @@ -218,6 +225,9 @@ func TestPricingFooterRemainsResponsiveAcrossPricedViews(t *testing.T) { model := Model{nextRefresh: time.Now().Add(time.Minute)} pricedViews := append([]meterViewID{viewBenchmark}, quotaViewOrder[:]...) for _, view := range pricedViews { + if view == viewResets { + continue + } model.meterView = view for _, width := range []int{12, 24, 40, 60, 79, 80, 100} { footer := model.renderFooter(width, paletteFor(themeHacker)) diff --git a/intro-post.md b/intro-post.md index d16f779..ea33f2d 100644 --- a/intro-post.md +++ b/intro-post.md @@ -57,7 +57,7 @@ API-equivalent figures are workload-dependent estimates, not your subscription Quota API-EQ also accounts for **requested Fast-mode premiums** on maintained Astra and GPT-5.6 models, per response, including applicable cache and long-context pricing. `TIER*` marks requested-tier estimates; `STD 100%` provides a standard-price comparison when space permits. Missing tier evidence is flagged `STD?` or `TIER*?` with LOW confidence. These are not confirmed charges: the observed data does not expose the actual billed tier, and quota percentages are never multiplied. Benchmark rankings remain standard-price comparisons. See the [Fast-mode estimation guidance](https://github.com/merefield/codexometer#fast-mode-and-service-tier-uncertainty). -The reset button normally appears only when Codex reports an available reset and a quota window is at least **80% consumed**. Change that threshold with `--reset-threshold 60`. Click once to reveal confirmation, then again within ten seconds to redeem; `Esc` cancels. A reset refreshes eligible quota and changes the weekly reset schedule—it does not stack additional allowance. +The reset button appears when Codex reports an available reset and a quota window is at least **80% consumed**, or a known reset expires in **less than 72 hours**. Change the usage threshold with `--reset-threshold 60`. The new **Quota → Resets** view is always accessible and shows available credits, grant dates, expiries and descriptions when supplied. Click the reset button once to open this view and reveal confirmation, then again within ten seconds to redeem; `Esc` cancels. Codexometer targets the soonest-expiring credit it can identify, keeping that credit fixed through confirmation and retries. If details are missing, it reports that limitation and lets the backend choose. A reset refreshes eligible quota and changes the weekly reset schedule—it does not stack additional allowance. Ordinary Codex CLI sessions work out of the box. For the best Sessions feedback, connect your CLI sessions through a shared local Codex app-server: Codexometer can distinguish **INPUT NEEDED** from **APPROVAL NEEDED**, show command-approval details with controls matching Codex's supported offered choices, and use positively matched resolved-model events for more accurate pricing. Permission grants require confirmation; session-wide and persistent-prefix choices are clearly labelled. Without that setup, it falls back to local session signals and a cautious **CHECK SESSION** inactivity prompt, not a guessed approval alert. See the [recommended setup](https://github.com/merefield/codexometer#recommended-codex-cli-setup). diff --git a/main.go b/main.go index 03ef6fd..24d15c3 100644 --- a/main.go +++ b/main.go @@ -273,7 +273,7 @@ func run(args []string, stdout, stderr io.Writer, deps dependencies) int { refresh = flags.Duration("refresh", time.Minute, "quota refresh interval") demo = flags.Bool("demo", false, "show the UI with simulated quota data") inline = flags.Bool("inline", false, "render inline instead of using the alternate screen") - resetThreshold = flags.Int("reset-threshold", 80, "quota consumption percentage required to show reset (0-100; 0 always shows available resets)") + resetThreshold = flags.Int("reset-threshold", 80, "show reset at this quota consumption (0-100; also shown if expiry is within 72h)") checkAuth = flags.Bool("check-auth", false, "verify access to the current Codex login and exit") digBenchGame = flags.String("digbench-game", "", "run one experimental DigBench game and exit") digBenchModel = flags.String("digbench-model", "gpt-5.6-sol", "Codex model for --digbench-game") From 5a652b34f67493fe1623fcf38b05e33e3559f7b5 Mon Sep 17 00:00:00 2001 From: merefield Date: Fri, 11 Sep 2026 13:30:56 +0100 Subject: [PATCH 2/4] FEAT: separate expiry warning navigation and configure lead time --- README.md | 23 +++++- internal/i18n/locales/da.json | 2 + internal/i18n/locales/de.json | 2 + internal/i18n/locales/en-GB.json | 2 + internal/i18n/locales/es.json | 2 + internal/i18n/locales/et.json | 2 + internal/i18n/locales/fi.json | 2 + internal/i18n/locales/fr.json | 2 + internal/i18n/locales/it.json | 2 + internal/i18n/locales/ja.json | 2 + internal/i18n/locales/nb.json | 2 + internal/i18n/locales/nl.json | 2 + internal/i18n/locales/pt-BR.json | 2 + internal/i18n/locales/pt-PT.json | 2 + internal/i18n/locales/ru.json | 2 + internal/i18n/locales/sv.json | 2 + internal/i18n/locales/tr.json | 2 + internal/i18n/locales/zh-Hans.json | 2 + internal/ui/localisation_test.go | 1 + internal/ui/model.go | 16 ++-- internal/ui/quota_reset.go | 114 ++++++++++++++++++++----- internal/ui/reset_credits_test.go | 7 +- internal/ui/reset_warning_test.go | 128 +++++++++++++++++++++++++++++ internal/ui/tabs.go | 10 +-- intro-post.md | 4 +- main.go | 34 ++++---- main_test.go | 44 ++++++++-- 27 files changed, 349 insertions(+), 66 deletions(-) create mode 100644 internal/ui/reset_warning_test.go diff --git a/README.md b/README.md index 45a63cb..e18e5c7 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ English rendering baseline captured from v0.12.0. - A countdown to the next automatic refresh. - Confirmed redemption of available banked quota resets, normally offered only when a displayed window is at least 80% consumed (configurable), or a known - available reset expires in less than 72 hours. A dedicated Quota → Resets view + available reset expires in less than 72 hours (configurable). A dedicated Quota → Resets view remains accessible below the usage threshold. - Account token history in a daily activity grid, weekly bars, or a cumulative graph, with a 6/12-month range and lifetime, peak-day, and streak summaries @@ -851,12 +851,25 @@ fixed through confirmation and any retry of an uncertain request; it never silently switches credits. A credit that expires or disappears before a new request is submitted requires a fresh confirmation. -An amber `EXPIRING` shortcut appears on Quota views when a known available reset -has less than 72 hours left, even below `--reset-threshold`. This is an expiry +An amber warning such as `⚠ RESET EXPIRES IN 2D 4H` appears immediately before +the normal `[ RESET // N ]` button when a known available reset has less than +72 hours left by default, even below `--reset-threshold`. Clicking the warning opens +**Quota → Resets** without arming confirmation or submitting a reset. It +underlines on hover, shortens on narrower terminals and moves onto an extra +row when necessary. This is an expiry warning, not a recommendation to reset unused quota. Redemption still requires fresh account data and explicit confirmation. The Resets view always permits review regardless of usage percentage. +Set the lead time with `--reset-warning-hours HOURS`: for example, +`./codexometer --reset-warning-hours 24` warns one day ahead, while +`./codexometer --reset-warning-hours 168` warns a week ahead (useful for testing +with a known later expiry). `--reset-warning-hours 0` disables expiry warnings +and their threshold bypass, without disabling the consumption-based reset +button or the Resets view. Use whole, non-negative hours. This setting does not +invent credit details or change expiry dates; it applies to the current launch. +Keep the option in your usual shell alias or launch command to retain it. + Credit details are optional and may be capped by the backend. The view shows how many of the available credits have usable details; earliest expiry means **earliest known**, not a guarantee about undisclosed credits. If only a count @@ -1909,6 +1922,7 @@ deterministic PASS/FAIL verifier. --inline render inline instead of using the alternate screen --refresh DURATION refresh interval (default: 1m) --reset-threshold PERCENT show available resets at this consumption level (0-100; default: 80) +--reset-warning-hours HOURS expiry warning lead time (default: 72; 0 disables) -v, --version print the version and exit ``` @@ -1930,7 +1944,8 @@ codexometer --codex ~/bin/codex On the Quota tab, `[ RESET // N ]` appears at the top right when a recent quota reading reports available banked resets, the account is verified, and at least one displayed quota window is **80% consumed or higher**, or a known -available reset expires in **less than 72 hours**. In the dedicated **Resets** +available reset expires within the warning lead time (**72 hours** by default; +configure with `--reset-warning-hours`). In the dedicated **Resets** view, the usage threshold does not apply. Set another threshold with `./codexometer --reset-threshold 60` (whole percentages from 0 to 100). For testing, `./codexometer --reset-threshold 0` bypasses the diff --git a/internal/i18n/locales/da.json b/internal/i18n/locales/da.json index 210fad5..e38a105 100644 --- a/internal/i18n/locales/da.json +++ b/internal/i18n/locales/da.json @@ -1,4 +1,6 @@ { + "⚠ RESET EXPIRES IN %s": "⚠ NULSTILLING UDLØBER OM %s", + "⚠ EXPIRES %s": "⚠ UDLØBER %s", "RESETS": "NULSTILLINGER", "╭ RESETS ╮": "╭ NULSTILLINGER ╮", "APPROVAL — OPEN DETAIL →": "GODKENDELSE — ÅBN DETALJER →", diff --git a/internal/i18n/locales/de.json b/internal/i18n/locales/de.json index 730e350..77611e3 100644 --- a/internal/i18n/locales/de.json +++ b/internal/i18n/locales/de.json @@ -1,4 +1,6 @@ { + "⚠ RESET EXPIRES IN %s": "⚠ RESET VERFÄLLT IN %s", + "⚠ EXPIRES %s": "⚠ VERFÄLLT %s", "RESETS": "ZURÜCKSETZUNGEN", "╭ RESETS ╮": "╭ ZURÜCKSETZUNGEN ╮", "APPROVAL — OPEN DETAIL →": "FREIGABE — DETAILS ÖFFNEN →", diff --git a/internal/i18n/locales/en-GB.json b/internal/i18n/locales/en-GB.json index 411569e..11ac3ad 100644 --- a/internal/i18n/locales/en-GB.json +++ b/internal/i18n/locales/en-GB.json @@ -1,4 +1,6 @@ { + "⚠ RESET EXPIRES IN %s": "⚠ RESET EXPIRES IN %s", + "⚠ EXPIRES %s": "⚠ EXPIRES %s", "RESETS": "RESETS", "╭ RESETS ╮": "╭ RESETS ╮", "APPROVAL — OPEN DETAIL →": "APPROVAL — OPEN DETAIL →", diff --git a/internal/i18n/locales/es.json b/internal/i18n/locales/es.json index 6931e94..88e7e02 100644 --- a/internal/i18n/locales/es.json +++ b/internal/i18n/locales/es.json @@ -1,4 +1,6 @@ { + "⚠ RESET EXPIRES IN %s": "⚠ REINICIO CADUCA EN %s", + "⚠ EXPIRES %s": "⚠ CADUCA %s", "RESETS": "REINICIOS", "╭ RESETS ╮": "╭ REINICIOS ╮", "APPROVAL — OPEN DETAIL →": "APROBACIÓN — ABRIR DETALLES →", diff --git a/internal/i18n/locales/et.json b/internal/i18n/locales/et.json index 38b3389..f30990d 100644 --- a/internal/i18n/locales/et.json +++ b/internal/i18n/locales/et.json @@ -1,4 +1,6 @@ { + "⚠ RESET EXPIRES IN %s": "⚠ LÄHTESTUS AEGUB %s PÄRAST", + "⚠ EXPIRES %s": "⚠ AEGUB %s", "RESETS": "LÄHTESTUSED", "╭ RESETS ╮": "╭ LÄHTESTUSED ╮", "APPROVAL — OPEN DETAIL →": "KINNITAMINE — AVA ÜKSIKASJAD →", diff --git a/internal/i18n/locales/fi.json b/internal/i18n/locales/fi.json index 06f40c7..bf119ac 100644 --- a/internal/i18n/locales/fi.json +++ b/internal/i18n/locales/fi.json @@ -1,4 +1,6 @@ { + "⚠ RESET EXPIRES IN %s": "⚠ NOLLAUS VANHENEE %s KULUTTUA", + "⚠ EXPIRES %s": "⚠ VANHENEE %s", "RESETS": "NOLLAUKSET", "╭ RESETS ╮": "╭ NOLLAUKSET ╮", "APPROVAL — OPEN DETAIL →": "HYVÄKSYNTÄ — AVAA TIEDOT →", diff --git a/internal/i18n/locales/fr.json b/internal/i18n/locales/fr.json index 143f028..5aa10ad 100644 --- a/internal/i18n/locales/fr.json +++ b/internal/i18n/locales/fr.json @@ -1,4 +1,6 @@ { + "⚠ RESET EXPIRES IN %s": "⚠ RÉINITIALISATION EXPIRE DANS %s", + "⚠ EXPIRES %s": "⚠ EXPIRE %s", "RESETS": "RÉINITIALISATIONS", "╭ RESETS ╮": "╭ RÉINITIALISATIONS ╮", "APPROVAL — OPEN DETAIL →": "APPROBATION — OUVRIR LES DÉTAILS →", diff --git a/internal/i18n/locales/it.json b/internal/i18n/locales/it.json index 66b6cac..545e5e0 100644 --- a/internal/i18n/locales/it.json +++ b/internal/i18n/locales/it.json @@ -1,4 +1,6 @@ { + "⚠ RESET EXPIRES IN %s": "⚠ RIPRISTINO SCADE TRA %s", + "⚠ EXPIRES %s": "⚠ SCADE %s", "RESETS": "RIPRISTINI", "╭ RESETS ╮": "╭ RIPRISTINI ╮", "APPROVAL — OPEN DETAIL →": "APPROVAZIONE — APRI DETTAGLI →", diff --git a/internal/i18n/locales/ja.json b/internal/i18n/locales/ja.json index 4d1d100..9133e09 100644 --- a/internal/i18n/locales/ja.json +++ b/internal/i18n/locales/ja.json @@ -1,4 +1,6 @@ { + "⚠ RESET EXPIRES IN %s": "⚠ リセット期限まで %s", + "⚠ EXPIRES %s": "⚠ 期限 %s", "RESETS": "リセット", "╭ RESETS ╮": "╭ リセット ╮", "APPROVAL — OPEN DETAIL →": "承認 — 詳細を開く →", diff --git a/internal/i18n/locales/nb.json b/internal/i18n/locales/nb.json index b053e4e..ca527da 100644 --- a/internal/i18n/locales/nb.json +++ b/internal/i18n/locales/nb.json @@ -1,4 +1,6 @@ { + "⚠ RESET EXPIRES IN %s": "⚠ NULLSTILLING UTLØPER OM %s", + "⚠ EXPIRES %s": "⚠ UTLØPER %s", "RESETS": "NULLSTILLINGER", "╭ RESETS ╮": "╭ NULLSTILLINGER ╮", "APPROVAL — OPEN DETAIL →": "GODKJENNING — ÅPNE DETALJER →", diff --git a/internal/i18n/locales/nl.json b/internal/i18n/locales/nl.json index 73071c7..6d02c5b 100644 --- a/internal/i18n/locales/nl.json +++ b/internal/i18n/locales/nl.json @@ -1,4 +1,6 @@ { + "⚠ RESET EXPIRES IN %s": "⚠ RESET VERLOOPT OVER %s", + "⚠ EXPIRES %s": "⚠ VERLOOPT %s", "RESETS": "RESETS", "╭ RESETS ╮": "╭ RESETS ╮", "APPROVAL — OPEN DETAIL →": "GOEDKEURING — OPEN DETAILS →", diff --git a/internal/i18n/locales/pt-BR.json b/internal/i18n/locales/pt-BR.json index a5a3cf9..e6e54b5 100644 --- a/internal/i18n/locales/pt-BR.json +++ b/internal/i18n/locales/pt-BR.json @@ -1,4 +1,6 @@ { + "⚠ RESET EXPIRES IN %s": "⚠ REDEFINIÇÃO EXPIRA EM %s", + "⚠ EXPIRES %s": "⚠ EXPIRA %s", "RESETS": "REDEFINIÇÕES", "╭ RESETS ╮": "╭ REDEFINIÇÕES ╮", "APPROVAL — OPEN DETAIL →": "APROVAÇÃO — ABRIR DETALHES →", diff --git a/internal/i18n/locales/pt-PT.json b/internal/i18n/locales/pt-PT.json index 18f4db2..f8b8e9e 100644 --- a/internal/i18n/locales/pt-PT.json +++ b/internal/i18n/locales/pt-PT.json @@ -1,4 +1,6 @@ { + "⚠ RESET EXPIRES IN %s": "⚠ REPOSIÇÃO EXPIRA EM %s", + "⚠ EXPIRES %s": "⚠ EXPIRA %s", "RESETS": "REPOSIÇÕES", "╭ RESETS ╮": "╭ REPOSIÇÕES ╮", "APPROVAL — OPEN DETAIL →": "APROVAÇÃO — ABRIR DETALHES →", diff --git a/internal/i18n/locales/ru.json b/internal/i18n/locales/ru.json index 82534cd..6457f26 100644 --- a/internal/i18n/locales/ru.json +++ b/internal/i18n/locales/ru.json @@ -1,4 +1,6 @@ { + "⚠ RESET EXPIRES IN %s": "⚠ СБРОС ИСТЕКАЕТ ЧЕРЕЗ %s", + "⚠ EXPIRES %s": "⚠ ИСТЕКАЕТ %s", "RESETS": "СБРОСЫ", "╭ RESETS ╮": "╭ СБРОСЫ ╮", "APPROVAL — OPEN DETAIL →": "ОДОБРЕНИЕ — ОТКРЫТЬ ПОДРОБНОСТИ →", diff --git a/internal/i18n/locales/sv.json b/internal/i18n/locales/sv.json index 82e45d8..eacc489 100644 --- a/internal/i18n/locales/sv.json +++ b/internal/i18n/locales/sv.json @@ -1,4 +1,6 @@ { + "⚠ RESET EXPIRES IN %s": "⚠ ÅTERSTÄLLNING UPPHÖR OM %s", + "⚠ EXPIRES %s": "⚠ UPPHÖR %s", "RESETS": "ÅTERSTÄLLNINGAR", "╭ RESETS ╮": "╭ ÅTERSTÄLLNINGAR ╮", "APPROVAL — OPEN DETAIL →": "GODKÄNNANDE — ÖPPNA DETALJER →", diff --git a/internal/i18n/locales/tr.json b/internal/i18n/locales/tr.json index 85d7207..2409e6c 100644 --- a/internal/i18n/locales/tr.json +++ b/internal/i18n/locales/tr.json @@ -1,4 +1,6 @@ { + "⚠ RESET EXPIRES IN %s": "⚠ SIFIRLAMA %s İÇİNDE SONA ERER", + "⚠ EXPIRES %s": "⚠ SONA ERER %s", "RESETS": "SIFIRLAMALAR", "╭ RESETS ╮": "╭ SIFIRLAMALAR ╮", "APPROVAL — OPEN DETAIL →": "ONAY — AYRINTILARI AÇ →", diff --git a/internal/i18n/locales/zh-Hans.json b/internal/i18n/locales/zh-Hans.json index 6c5a6ad..dd361e1 100644 --- a/internal/i18n/locales/zh-Hans.json +++ b/internal/i18n/locales/zh-Hans.json @@ -1,4 +1,6 @@ { + "⚠ RESET EXPIRES IN %s": "⚠ 重置将在 %s 后过期", + "⚠ EXPIRES %s": "⚠ 过期 %s", "RESETS": "重置", "╭ RESETS ╮": "╭ 重置 ╮", "APPROVAL — OPEN DETAIL →": "审批 — 打开详情 →", diff --git a/internal/ui/localisation_test.go b/internal/ui/localisation_test.go index c145f3e..714b291 100644 --- a/internal/ui/localisation_test.go +++ b/internal/ui/localisation_test.go @@ -55,6 +55,7 @@ func TestLocalisedScreensHelper(t *testing.T) { t.Run("monitor_prompt_wrapping", TestMonitorPromptWrapsAndGrowsUpward) t.Run("monitor_detail_sections", TestMonitorDetailStructuredSections) t.Run("tab_click_surfaces", TestEveryRenderedTabCellIsClickableAcrossWidths) + t.Run("reset_warning_surfaces", TestResetWarningClickSurfaces) t.Run("header_click_surfaces", TestHeaderClickTargets) t.Run("detail_activity", TestDetailSentWaveLifecycle) t.Run("session_context_activity", TestMainSessionContextDots) diff --git a/internal/ui/model.go b/internal/ui/model.go index 595c934..a46b5ce 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -62,7 +62,9 @@ type Model struct { versionHovered bool history accountHistoryState resetThreshold int + resetWarningHours int resetHovered bool + resetWarningHovered bool resetBusy bool resetKey, resetAccount, resetNotice string resetCreditID string @@ -399,6 +401,7 @@ func New(fetcher Fetcher, refreshEvery time.Duration) Model { } model := Model{ resetThreshold: 80, + resetWarningHours: 72, fetcher: fetcher, refreshEvery: refreshEvery, monitorAutoStart: true, @@ -746,6 +749,11 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } } m.resetHovered = m.resetAt(mouse.X, mouse.Y) + m.resetWarningHovered = m.resetWarningAt(mouse.X, mouse.Y) + if m.resetWarningHovered && clicked && mouse.Button == tea.MouseLeft { + m.resetScroll = 0 + return m.pressViewTab(viewResets) + } if m.resetAt(mouse.X, mouse.Y) && clicked && mouse.Button == tea.MouseLeft { return m.pressQuotaReset() } @@ -1379,9 +1387,7 @@ func (m Model) dashboardLayout() dashboardGeometry { if m.meterView.isQuota() { tabsHeight++ } - if m.resetOwnRow(contentWidth) { - tabsHeight++ - } + tabsHeight += m.resetControlsLayout(contentWidth).extraRows const framedErrorHeight = 3 const footerHeight = 2 extraHeight := 0 @@ -1405,9 +1411,7 @@ func (m Model) dashboardLayout() dashboardGeometry { quotaTabsY := -1 if m.meterView.isQuota() { quotaTabsY = tabsY + 1 - if m.resetOwnRow(contentWidth) { - quotaTabsY++ - } + quotaTabsY += m.resetControlsLayout(contentWidth).extraRows } meterY := tabsY + tabsHeight + extraHeight meterHeight := max(contentHeight-headerHeight-statusHeight-tabsHeight-extraHeight-footerHeight, 1) diff --git a/internal/ui/quota_reset.go b/internal/ui/quota_reset.go index f473dc3..4cab6f8 100644 --- a/internal/ui/quota_reset.go +++ b/internal/ui/quota_reset.go @@ -24,7 +24,16 @@ type resetCreditConsumer interface { ConsumeResetCredit(context.Context, string, string, string) (string, error) } -const resetExpiryWarning = 72 * time.Hour +// MaxResetWarningHours prevents overflow when converting CLI hours to duration. +const MaxResetWarningHours = int((1<<63 - 1) / int64(time.Hour)) + +func (m *Model) SetResetWarningHours(hours int) { + m.resetWarningHours = min(max(hours, 0), MaxResetWarningHours) +} + +func (m Model) resetExpiryWarning() time.Duration { + return time.Duration(m.resetWarningHours) * time.Hour +} // Details may be absent or capped. Never infer expiry from the quota window. func (m Model) availableResetCredits() []codex.ResetCredit { @@ -61,7 +70,7 @@ func (m Model) availableResetCredits() []codex.ResetCredit { func (m Model) resetExpiringSoon() bool { credits := m.availableResetCredits() - return len(credits) > 0 && credits[0].ExpiresAt != nil && time.Until(time.Unix(*credits[0].ExpiresAt, 0)) < resetExpiryWarning + return m.resetWarningHours > 0 && len(credits) > 0 && credits[0].ExpiresAt != nil && time.Until(time.Unix(*credits[0].ExpiresAt, 0)) < m.resetExpiryWarning() } // SetResetThreshold sets the consumed percentage required to offer a reset. @@ -70,9 +79,6 @@ func (m *Model) SetResetThreshold(percent int) { m.resetThreshold = min(max(perc func (m Model) renderResetButton(label string, colors palette) string { style := colors.label() - if m.resetExpiringSoon() { - style = style.Foreground(colors.warning) - } if m.resetHovered && !m.resetBusy { style = style.Foreground(colors.background).Background(colors.primary) } @@ -135,37 +141,101 @@ func (m Model) resetFullLabel() string { return "" } } - if m.resetExpiringSoon() { - if m.width > 0 && m.width < 40 { - return fmt.Sprintf("[ RESET // %d ! ]", m.snapshot.RateLimitResetCredits.AvailableCount) + return i18n.Format("[ RESET // %d ]", m.snapshot.RateLimitResetCredits.AvailableCount) +} + +type resetControls struct { + button, warning string + buttonX, buttonY, warningX, warningY int + tabsWidth, extraRows int +} + +// One geometry source for rendering, tab allocation and both click surfaces. +func (m Model) resetControlsLayout(width int) resetControls { + c := resetControls{tabsWidth: width, button: m.resetLabel()} + if c.button == "" { + return c + } + c.buttonX = max(width-lipgloss.Width(c.button), 0) + credits := m.availableResetCredits() + if m.resetWarningHours > 0 && len(credits) > 0 && credits[0].ExpiresAt != nil && time.Until(time.Unix(*credits[0].ExpiresAt, 0)) < m.resetExpiryWarning() && !m.loading && m.err == nil && !m.snapshot.FetchedAt.IsZero() && time.Since(m.snapshot.FetchedAt) <= 2*m.refreshEvery { + hours := max(int(time.Until(time.Unix(*credits[0].ExpiresAt, 0)).Hours()), 0) + remaining := fmt.Sprintf("%dH", hours) + if hours >= 24 { + remaining = fmt.Sprintf("%dD %dH", hours/24, hours%24) + } else if hours == 0 { + remaining = "<1H" + } + c.warning = i18n.Format("⚠ RESET EXPIRES IN %s", remaining) + if lipgloss.Width(c.warning)+1+lipgloss.Width(c.button)+12 > width { + if hours >= 24 { + remaining = fmt.Sprintf("%dD", hours/24) + } + c.warning = i18n.Format("⚠ EXPIRES %s", remaining) } - return fmt.Sprintf("[ RESET // %d // EXPIRING ]", m.snapshot.RateLimitResetCredits.AvailableCount) } - return i18n.Format("[ RESET // %d ]", m.snapshot.RateLimitResetCredits.AvailableCount) + total := lipgloss.Width(c.button) + if c.warning != "" { + total += lipgloss.Width(c.warning) + 1 + } + if total+12 <= width { + c.tabsWidth = width - total - 1 + } else { + c.extraRows, c.buttonY, c.warningY = 1, 1, 1 + if total > width && c.warning != "" { + c.extraRows, c.buttonY = 2, 2 + c.warning = ansi.Truncate(c.warning, width, "") + } + } + c.warningX = max(c.buttonX-lipgloss.Width(c.warning)-1, 0) + if c.warningY != c.buttonY { + c.warningX = max(width-lipgloss.Width(c.warning), 0) + } + return c } -// Reserve the same horizontal span for rendering and hit testing. func (m Model) resetLayout(width int) (int, string) { - label := m.resetLabel() - if label == "" || width < lipgloss.Width(label)+12 { + c := m.resetControlsLayout(width) + if c.extraRows > 0 { return width, "" } - return width - lipgloss.Width(label) - 1, label + return c.tabsWidth, c.button } func (m Model) resetAt(x, y int) bool { g := m.dashboardLayout() - w, label := m.resetLayout(g.contentWidth) - if m.resetOwnRow(g.contentWidth) { - label = m.resetLabel() - return y == g.tabsY+1 && x >= 2+max(g.contentWidth-lipgloss.Width(label), 0) && x < 2+g.contentWidth + c := m.resetControlsLayout(g.contentWidth) + return c.button != "" && !(m.loading && len(m.snapshot.Meters()) == 0) && y == g.tabsY+c.buttonY && x >= 2+c.buttonX && x < 2+c.buttonX+lipgloss.Width(c.button) +} + +func (m Model) resetWarningAt(x, y int) bool { + g := m.dashboardLayout() + c := m.resetControlsLayout(g.contentWidth) + return c.warning != "" && !(m.loading && len(m.snapshot.Meters()) == 0) && y == g.tabsY+c.warningY && x >= 2+c.warningX && x < 2+c.warningX+lipgloss.Width(c.warning) +} + +func (m Model) renderResetControls(width int, tabs string, colors palette) string { + c := m.resetControlsLayout(width) + if c.button == "" { + return tabs + } + rows := []string{tabs} + for range c.extraRows { + rows = append(rows, "") + } + if c.warning != "" { + style := colors.label().Foreground(colors.warning) + if m.resetWarningHovered { + style = style.Underline(true) + } + rows[c.warningY] += strings.Repeat(" ", max(c.warningX-lipgloss.Width(rows[c.warningY]), 0)) + style.Render(c.warning) } - return label != "" && !(m.loading && len(m.snapshot.Meters()) == 0) && y == g.tabsY && x >= 2+w+1 && x < 2+g.contentWidth + rows[c.buttonY] += strings.Repeat(" ", max(c.buttonX-lipgloss.Width(rows[c.buttonY]), 0)) + m.renderResetButton(c.button, colors) + return strings.Join(rows, "\n") } func (m Model) resetOwnRow(width int) bool { - label := m.resetLabel() - return label != "" && width < lipgloss.Width(label)+12 + return m.resetControlsLayout(width).extraRows > 0 } func (m Model) pressQuotaReset() (tea.Model, tea.Cmd) { @@ -281,7 +351,7 @@ func (m Model) resetDetailLines(width int, colors palette) (result []string) { lines := []string{fmt.Sprintf("AVAILABLE // %d", summary.AvailableCount)} credits := m.availableResetCredits() if m.resetExpiringSoon() { - lines = append(lines, colors.label().Foreground(colors.warning).Render("EXPIRING SOON // within 72 hours")) + lines = append(lines, colors.label().Foreground(colors.warning).Render(fmt.Sprintf("EXPIRING SOON // within %d hours", m.resetWarningHours))) } if summary.AvailableCount == 0 { return append(lines, "No resets available.") diff --git a/internal/ui/reset_credits_test.go b/internal/ui/reset_credits_test.go index eb8f9c4..eb4cf77 100644 --- a/internal/ui/reset_credits_test.go +++ b/internal/ui/reset_credits_test.go @@ -41,7 +41,7 @@ func TestResetCreditOrderingAndWarning(t *testing.T) { if len(got) != 3 || got[0].ID != "first" || got[2].ID != "forever" { t.Fatalf("order: %#v", got) } - if !m.resetExpiringSoon() || !strings.Contains(m.resetLabel(), "EXPIRING") { + if !m.resetExpiringSoon() || m.resetControlsLayout(100).warning == "" || m.resetLabel() != "[ RESET // 8 ]" { t.Fatal("expiry did not bypass threshold") } m.snapshot.RateLimitResetCredits.Credits = []codex.ResetCredit{credit("later", 73*time.Hour)} @@ -148,10 +148,7 @@ func TestResetsResponsiveGeometryAndScroll(t *testing.T) { if lipgloss.Width(rendered) > g.contentWidth || lipgloss.Height(rendered) != g.meterHeight { t.Fatalf("%d: wrong dimensions", width) } - row := g.tabsY - if m.resetOwnRow(g.contentWidth) { - row++ - } + row := g.tabsY + m.resetControlsLayout(g.contentWidth).buttonY label := m.resetLabel() for x := 2 + g.contentWidth - lipgloss.Width(label); x < 2+g.contentWidth; x++ { if !m.resetAt(x, row) { diff --git a/internal/ui/reset_warning_test.go b/internal/ui/reset_warning_test.go new file mode 100644 index 0000000..de03aef --- /dev/null +++ b/internal/ui/reset_warning_test.go @@ -0,0 +1,128 @@ +package ui + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/merefield/codexometer/internal/codex" +) + +func TestResetWarningClickSurfaces(t *testing.T) { + for _, width := range []int{20, 24, 28, 40, 60, 80, 120, 180} { + for _, state := range []string{"ready", "confirm", "retry", "busy"} { + m, f := resetModel() + m.width, m.height = width, 60 + m.snapshot.RateLimits.Primary.UsedPercent = 1 + m.snapshot.RateLimitResetCredits.Credits = []codex.ResetCredit{credit("first", 51*time.Hour)} + switch state { + case "confirm": + m.resetConfirmUntil = time.Now().Add(10 * time.Second) + case "retry": + m.resetKey = "uncertain-attempt" + case "busy": + m.resetBusy = true + } + g := m.dashboardLayout() + c := m.resetControlsLayout(g.contentWidth) + if c.warning == "" { + t.Fatalf("%d %s: missing warning", width, state) + } + rows := strings.Split(ansi.Strip(m.renderMainTabs(g.contentWidth, paletteFor(m.theme))), "\n") + if len(rows) != c.extraRows+1 { + t.Fatal("row allocation mismatch") + } + for _, row := range rows { + if lipgloss.Width(row) > g.contentWidth { + t.Fatalf("%d %s overflow: %q", width, state, row) + } + } + for _, target := range []struct { + text string + x, y int + warning bool + }{{c.warning, c.warningX, c.warningY, true}, {c.button, c.buttonX, c.buttonY, false}} { + index := strings.Index(rows[target.y], target.text) + if index < 0 || lipgloss.Width(rows[target.y][:index]) != target.x { + t.Fatalf("%d %s: render disagrees with hit geometry", width, state) + } + for offset := range lipgloss.Width(target.text) { + x, y := 2+target.x+offset, g.tabsY+target.y + if m.resetWarningAt(x, y) != target.warning || m.resetAt(x, y) == target.warning { + t.Fatal("overlapping or missing target") + } + if _, hit := m.mainTabAt(x, y); hit { + t.Fatal("overlaps main tab") + } + if _, hit := m.quotaViewTabAt(x, y); hit { + t.Fatal("overlaps quota view") + } + if target.warning { + u, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft}) + n := u.(Model) + if n.meterView != viewResets || !n.resetConfirmUntil.IsZero() || len(f.keys) != 0 || n.resetKey != m.resetKey || n.resetBusy != m.resetBusy { + t.Fatal("warning did more than navigate/cancel confirmation") + } + } + } + } + if g.quotaTabsY != g.tabsY+c.extraRows+1 { + t.Fatal("quota tabs not moved with warning") + } + } + } +} + +func TestResetWarningVisibility(t *testing.T) { + m, _ := resetModel() + for _, age := range []time.Duration{-time.Hour, 73 * time.Hour} { + m.snapshot.RateLimitResetCredits.Credits = []codex.ResetCredit{credit("first", age)} + if m.resetControlsLayout(100).warning != "" { + t.Fatal("warned outside expiry interval") + } + } + m.snapshot.RateLimitResetCredits.Credits = []codex.ResetCredit{credit("first", 30*time.Minute)} + if !strings.Contains(m.resetControlsLayout(100).warning, "<1H") { + t.Fatal("sub-hour expiry not shown") + } + m.snapshot.FetchedAt = time.Now().Add(-3 * time.Minute) + if m.resetControlsLayout(100).warning != "" { + t.Fatal("warned from stale data") + } + m.snapshot.FetchedAt = time.Now() + m.meterView = viewMonitor + if m.resetControlsLayout(100).warning != "" { + t.Fatal("warning leaked outside quota") + } +} + +func TestResetWarningHoursOverride(t *testing.T) { + m, _ := resetModel() + m.snapshot.RateLimits.Primary.UsedPercent = 1 + m.snapshot.RateLimitResetCredits.Credits = []codex.ResetCredit{credit("first", 96*time.Hour)} + if m.resetWarningHours != 72 || m.resetExpiringSoon() || m.resetLabel() != "" { + t.Fatal("wrong default") + } + m.SetResetWarningHours(168) + if !m.resetExpiringSoon() || m.resetControlsLayout(100).warning == "" || m.resetLabel() == "" { + t.Fatal("larger lead time ignored") + } + if !strings.Contains(strings.Join(m.resetDetailLines(100, paletteFor(m.theme)), "\n"), "168 hours") { + t.Fatal("detail warning kept fixed lead time") + } + m.SetResetWarningHours(0) + if m.resetExpiringSoon() || m.resetLabel() != "" || m.resetControlsLayout(100).warning != "" { + t.Fatal("zero did not disable warning") + } + m.snapshot.RateLimits.Primary.UsedPercent = 80 + if m.resetLabel() == "" { + t.Fatal("disabled expiry warning suppressed consumption threshold") + } + m.SetResetWarningHours(-1) + if m.resetWarningHours != 0 { + t.Fatal("negative duration") + } +} diff --git a/internal/ui/tabs.go b/internal/ui/tabs.go index ee0fe62..8f0d8dd 100644 --- a/internal/ui/tabs.go +++ b/internal/ui/tabs.go @@ -171,7 +171,7 @@ func mainTabForView(view meterViewID) mainTabID { } func (m Model) renderMainTabs(width int, colors palette) string { - tabWidth, resetLabel := m.resetLayout(width) + tabWidth, _ := m.resetLayout(width) tabs, separator := mainTabLayout(tabWidth, true) parts := make([]string, 0, len(tabs)) used := 0 @@ -186,13 +186,7 @@ func (m Model) renderMainTabs(width int, colors palette) string { if len(parts) > 1 { used += (len(parts) - 1) * len(separator) } - if resetLabel != "" { - return strings.Join(parts, colors.dimmed().Render(separator)) + strings.Repeat(" ", max(tabWidth-used+1, 0)) + m.renderResetButton(resetLabel, colors) - } - if m.resetOwnRow(width) { - return strings.Join(parts, colors.dimmed().Render(separator)) + "\n" + joinRight("", m.renderResetButton(m.resetLabel(), colors), width) - } - return strings.Join(parts, colors.dimmed().Render(separator)) + strings.Repeat(" ", max(width-used, 0)) + return m.renderResetControls(width, strings.Join(parts, colors.dimmed().Render(separator))+strings.Repeat(" ", max(tabWidth-used, 0)), colors) } func (m Model) renderQuotaViewTabs(width int, colors palette) string { diff --git a/intro-post.md b/intro-post.md index ea33f2d..e4f95b0 100644 --- a/intro-post.md +++ b/intro-post.md @@ -57,10 +57,12 @@ API-equivalent figures are workload-dependent estimates, not your subscription Quota API-EQ also accounts for **requested Fast-mode premiums** on maintained Astra and GPT-5.6 models, per response, including applicable cache and long-context pricing. `TIER*` marks requested-tier estimates; `STD 100%` provides a standard-price comparison when space permits. Missing tier evidence is flagged `STD?` or `TIER*?` with LOW confidence. These are not confirmed charges: the observed data does not expose the actual billed tier, and quota percentages are never multiplied. Benchmark rankings remain standard-price comparisons. See the [Fast-mode estimation guidance](https://github.com/merefield/codexometer#fast-mode-and-service-tier-uncertainty). -The reset button appears when Codex reports an available reset and a quota window is at least **80% consumed**, or a known reset expires in **less than 72 hours**. Change the usage threshold with `--reset-threshold 60`. The new **Quota → Resets** view is always accessible and shows available credits, grant dates, expiries and descriptions when supplied. Click the reset button once to open this view and reveal confirmation, then again within ten seconds to redeem; `Esc` cancels. Codexometer targets the soonest-expiring credit it can identify, keeping that credit fixed through confirmation and retries. If details are missing, it reports that limitation and lets the backend choose. A reset refreshes eligible quota and changes the weekly reset schedule—it does not stack additional allowance. +The reset button appears when Codex reports an available reset and a quota window is at least **80% consumed**, or a known reset expires in **less than 72 hours**. Change the usage threshold with `--reset-threshold 60`. The new **Quota → Resets** view is always accessible and shows available credits, grant dates, expiries and descriptions when supplied. A separate amber expiry countdown beside the button opens this view without arming confirmation; it shortens or moves to another row on narrow terminals. Click the reset button once to open this view and reveal confirmation, then again within ten seconds to redeem; `Esc` cancels. Codexometer targets the soonest-expiring credit it can identify, keeping that credit fixed through confirmation and retries. If details are missing, it reports that limitation and lets the backend choose. A reset refreshes eligible quota and changes the weekly reset schedule—it does not stack additional allowance. Ordinary Codex CLI sessions work out of the box. For the best Sessions feedback, connect your CLI sessions through a shared local Codex app-server: Codexometer can distinguish **INPUT NEEDED** from **APPROVAL NEEDED**, show command-approval details with controls matching Codex's supported offered choices, and use positively matched resolved-model events for more accurate pricing. Permission grants require confirmation; session-wide and persistent-prefix choices are clearly labelled. Without that setup, it falls back to local session signals and a cautious **CHECK SESSION** inactivity prompt, not a guessed approval alert. See the [recommended setup](https://github.com/merefield/codexometer#recommended-codex-cli-setup). +Use `--reset-warning-hours 24` to warn one day ahead, or `--reset-warning-hours 168` for a week (handy for testing known later expiries). The default is 72 hours; `0` disables expiry warnings while retaining the consumption-based reset button. + ## Make it yours Choose Hacker, Rust, Blue Steel, Ultraviolet, or Nightshade with `t`. UK English remains the default and retains the original presentation. Set `CODEXOMETER_LANG` to opt into Dutch (`nl`), German (`de`), French (`fr`), Italian (`it`), Spanish (`es`), Russian (`ru`), Japanese (`ja`), Simplified Chinese (`zh-Hans`), Swedish (`sv`), Norwegian Bokmål (`nb`, also `no`), Turkish (`tr`), Estonian (`et`), Finnish (`fi`), Brazilian Portuguese (`pt-BR`), European Portuguese (`pt-PT`), or Danish (`da`): diff --git a/main.go b/main.go index 24d15c3..0b75ae9 100644 --- a/main.go +++ b/main.go @@ -246,7 +246,7 @@ type dependencies struct { checkAuth func(context.Context, string) (codex.Snapshot, error) listDigBenchGames func(context.Context, string) ([]string, error) runDigBench func(context.Context, string, string, string, codex.DigBenchOptions) (codex.DigBenchResult, error) - startUI func(ui.Fetcher, time.Duration, bool, int) error + startUI func(ui.Fetcher, time.Duration, bool, int, int) error } func defaultDependencies() dependencies { @@ -269,17 +269,18 @@ func run(args []string, stdout, stderr io.Writer, deps dependencies) int { flags := flag.NewFlagSet("codexometer", flag.ContinueOnError) flags.SetOutput(stderr) var ( - codexPath = flags.String("codex", "codex", "path to the Codex CLI") - refresh = flags.Duration("refresh", time.Minute, "quota refresh interval") - demo = flags.Bool("demo", false, "show the UI with simulated quota data") - inline = flags.Bool("inline", false, "render inline instead of using the alternate screen") - resetThreshold = flags.Int("reset-threshold", 80, "show reset at this quota consumption (0-100; also shown if expiry is within 72h)") - checkAuth = flags.Bool("check-auth", false, "verify access to the current Codex login and exit") - digBenchGame = flags.String("digbench-game", "", "run one experimental DigBench game and exit") - digBenchModel = flags.String("digbench-model", "gpt-5.6-sol", "Codex model for --digbench-game") - digBenchEffort = flags.String("digbench-effort", "high", "reasoning effort for --digbench-game") - digBenchTimeout = flags.Duration("digbench-timeout", codex.DefaultDigBenchTimeout, "hard limit for --digbench-game") - printVersion bool + codexPath = flags.String("codex", "codex", "path to the Codex CLI") + refresh = flags.Duration("refresh", time.Minute, "quota refresh interval") + demo = flags.Bool("demo", false, "show the UI with simulated quota data") + inline = flags.Bool("inline", false, "render inline instead of using the alternate screen") + resetThreshold = flags.Int("reset-threshold", 80, "show reset at this quota consumption (0-100; also shown for expiring credits)") + resetWarningHours = flags.Int("reset-warning-hours", 72, "warn this many hours before a reset credit expires (0 disables expiry warnings)") + checkAuth = flags.Bool("check-auth", false, "verify access to the current Codex login and exit") + digBenchGame = flags.String("digbench-game", "", "run one experimental DigBench game and exit") + digBenchModel = flags.String("digbench-model", "gpt-5.6-sol", "Codex model for --digbench-game") + digBenchEffort = flags.String("digbench-effort", "high", "reasoning effort for --digbench-game") + digBenchTimeout = flags.Duration("digbench-timeout", codex.DefaultDigBenchTimeout, "hard limit for --digbench-game") + printVersion bool ) flags.BoolVar(&printVersion, "version", false, "print the version and exit") flags.BoolVar(&printVersion, "v", false, "print the version and exit") @@ -295,6 +296,10 @@ func run(args []string, stdout, stderr io.Writer, deps dependencies) int { fmt.Fprintln(stderr, "codexometer: --reset-threshold must be between 0 and 100") return 2 } + if *resetWarningHours < 0 || *resetWarningHours > ui.MaxResetWarningHours { + fmt.Fprintf(stderr, "codexometer: --reset-warning-hours must be between 0 and %d\n", ui.MaxResetWarningHours) + return 2 + } // Capture credentials for the benchmark components, then remove them before // any path that can launch Codex. This keeps auth checks and quota reads on // the user's prevailing Codex login instead of leaking or implicitly using a @@ -375,7 +380,7 @@ func run(args []string, stdout, stderr io.Writer, deps dependencies) int { fetcher = &demoFetcher{} } - if err := deps.startUI(fetcher, *refresh, *inline, *resetThreshold); err != nil { + if err := deps.startUI(fetcher, *refresh, *inline, *resetThreshold, *resetWarningHours); err != nil { fmt.Fprintln(stderr, "codexometer:", err) return 1 } @@ -491,13 +496,14 @@ func formatDigBenchResult(result codex.DigBenchResult) string { return line } -func startUI(fetcher ui.Fetcher, refresh time.Duration, inline bool, resetThreshold int) error { +func startUI(fetcher ui.Fetcher, refresh time.Duration, inline bool, resetThreshold, resetWarningHours int) error { model := ui.New(fetcher, refresh) if store, storeErr := ui.NewDefaultPreferenceStore(); storeErr == nil { model = ui.NewWithPreferences(fetcher, refresh, store) } model.SetInline(inline) model.SetResetThreshold(resetThreshold) + model.SetResetWarningHours(resetWarningHours) _, err := tea.NewProgram(model).Run() return err } diff --git a/main_test.go b/main_test.go index 3daf146..9e659a4 100644 --- a/main_test.go +++ b/main_test.go @@ -125,7 +125,7 @@ func TestResetThresholdOption(t *testing.T) { t.Run(test.value, func(t *testing.T) { var stdout, stderr bytes.Buffer called := false - deps := dependencies{startUI: func(_ ui.Fetcher, _ time.Duration, _ bool, threshold int) error { + deps := dependencies{startUI: func(_ ui.Fetcher, _ time.Duration, _ bool, threshold, _ int) error { called = true if threshold != test.want { t.Fatalf("threshold %d, want %d", threshold, test.want) @@ -150,9 +150,9 @@ func TestRunStartsDemoWithSelectedOptions(t *testing.T) { var stdout, stderr bytes.Buffer called := false deps := dependencies{ - startUI: func(fetcher ui.Fetcher, refresh time.Duration, inline bool, resetThreshold int) error { + startUI: func(fetcher ui.Fetcher, refresh time.Duration, inline bool, resetThreshold, warningHours int) error { called = true - if refresh != 30*time.Second || !inline || resetThreshold != 35 { + if refresh != 30*time.Second || !inline || resetThreshold != 35 || warningHours != 72 { t.Fatalf("refresh=%s inline=%v", refresh, inline) } usageFetcher, ok := fetcher.(ui.TokenUsageFetcher) @@ -192,6 +192,38 @@ func TestRunStartsDemoWithSelectedOptions(t *testing.T) { } } +func TestRunResetWarningHours(t *testing.T) { + for _, test := range []struct { + value string + want, code int + }{ + {"", 72, 0}, {"0", 0, 0}, {"24", 24, 0}, {"168", 168, 0}, + {"-1", 0, 2}, {"abc", 0, 2}, {"1.5", 0, 2}, {"2562048", 0, 2}, {"99999999999999999999", 0, 2}, + } { + t.Run(test.value, func(t *testing.T) { + var stdout, stderr bytes.Buffer + called := false + deps := dependencies{startUI: func(_ ui.Fetcher, _ time.Duration, _ bool, _ int, hours int) error { + called = true + if hours != test.want { + t.Fatalf("got %d hours, want %d", hours, test.want) + } + return nil + }} + args := []string{"--demo"} + if test.value != "" { + args = append(args, "--reset-warning-hours", test.value) + } + if code := run(args, &stdout, &stderr, deps); code != test.code { + t.Fatalf("code %d: %s", code, stderr.String()) + } + if called != (test.code == 0) { + t.Fatal("invalid hours launched UI") + } + }) + } +} + func sameDemoAccounting(left, right codex.LiveUsageSnapshot) bool { return left.APIEqUSD == right.APIEqUSD && left.APIEqPricedCalls == right.APIEqPricedCalls && left.APIEqUnpricedCalls == right.APIEqUnpricedCalls && @@ -201,7 +233,7 @@ func sameDemoAccounting(left, right codex.LiveUsageSnapshot) bool { func TestRunReportsUIError(t *testing.T) { var stdout, stderr bytes.Buffer deps := dependencies{ - startUI: func(ui.Fetcher, time.Duration, bool, int) error { return errors.New("terminal unavailable") }, + startUI: func(ui.Fetcher, time.Duration, bool, int, int) error { return errors.New("terminal unavailable") }, } code := run(nil, &stdout, &stderr, deps) if code != 1 || !strings.Contains(stderr.String(), "terminal unavailable") { @@ -292,7 +324,7 @@ func TestRunPassesOpenAIAPIKeyToUIBenchmarks(t *testing.T) { t.Setenv("OPENAI_API_KEY", "openai-secret") var stdout, stderr bytes.Buffer deps := dependencies{ - startUI: func(fetcher ui.Fetcher, _ time.Duration, _ bool, _ int) error { + startUI: func(fetcher ui.Fetcher, _ time.Duration, _ bool, _, _ int) error { client, ok := fetcher.(codex.Client) if !ok || client.BenchmarkAPIKey != "openai-secret" { t.Fatalf("benchmark API key was not attached to Codex client") @@ -318,7 +350,7 @@ func TestRunPassesDigBenchTokenToUIWithoutLeavingItInEnvironment(t *testing.T) { } return []string{"P-2", "P-1", "P-2"}, nil }, - startUI: func(fetcher ui.Fetcher, _ time.Duration, _ bool, _ int) error { + startUI: func(fetcher ui.Fetcher, _ time.Duration, _ bool, _, _ int) error { client, ok := fetcher.(codex.Client) if !ok || client.DigBenchToken != "digbench-secret" { t.Fatalf("DigBench token was not attached to Codex client") From f1ce3bb911479f66a3f2d8f74d5d10fae255bb3e Mon Sep 17 00:00:00 2001 From: merefield Date: Fri, 11 Sep 2026 13:37:27 +0100 Subject: [PATCH 3/4] FIX: clarify reset trade-offs and incomplete expiry information --- README.md | 7 ++++ internal/i18n/locales/da.json | 3 ++ internal/i18n/locales/de.json | 3 ++ internal/i18n/locales/en-GB.json | 3 ++ internal/i18n/locales/es.json | 3 ++ internal/i18n/locales/et.json | 3 ++ internal/i18n/locales/fi.json | 3 ++ internal/i18n/locales/fr.json | 3 ++ internal/i18n/locales/it.json | 3 ++ internal/i18n/locales/ja.json | 3 ++ internal/i18n/locales/nb.json | 3 ++ internal/i18n/locales/nl.json | 3 ++ internal/i18n/locales/pt-BR.json | 3 ++ internal/i18n/locales/pt-PT.json | 3 ++ internal/i18n/locales/ru.json | 3 ++ internal/i18n/locales/sv.json | 3 ++ internal/i18n/locales/tr.json | 3 ++ internal/i18n/locales/zh-Hans.json | 3 ++ internal/ui/english_snapshot_test.go | 4 +-- internal/ui/quota_reset.go | 26 ++++++++++++-- internal/ui/reset_credits_test.go | 51 +++++++++++++++++++++++++++- intro-post.md | 2 ++ 22 files changed, 136 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e18e5c7..f91c0dc 100644 --- a/README.md +++ b/README.md @@ -861,6 +861,13 @@ warning, not a recommendation to reset unused quota. Redemption still requires fresh account data and explicit confirmation. The Resets view always permits review regardless of usage percentage. +The confirmation explicitly warns that unused allowance does not carry over or +stack and that the weekly reset schedule changes. Treat an expiry warning as a +prompt to **review**, not a recommendation to redeem immediately. When expiry +information is missing or incomplete, both the inventory and confirmation say +so: no warning does not prove there is no upcoming expiry, and undisclosed +credits may expire sooner than the selected known credit. + Set the lead time with `--reset-warning-hours HOURS`: for example, `./codexometer --reset-warning-hours 24` warns one day ahead, while `./codexometer --reset-warning-hours 168` warns a week ahead (useful for testing diff --git a/internal/i18n/locales/da.json b/internal/i18n/locales/da.json index e38a105..bbab707 100644 --- a/internal/i18n/locales/da.json +++ b/internal/i18n/locales/da.json @@ -1,4 +1,7 @@ { + "Unused allowance does not carry over or stack.": "Ubrugt kvote overføres ikke eller lægges til.", + "Expiry information unavailable. No warning does not mean no expiry.": "Udløbsoplysninger er ikke tilgængelige. Ingen advarsel betyder ikke, at intet udløber.", + "Expiry information incomplete. Other credits may expire sooner.": "Udløbsoplysningerne er ufuldstændige. Andre kreditter kan udløbe tidligere.", "⚠ RESET EXPIRES IN %s": "⚠ NULSTILLING UDLØBER OM %s", "⚠ EXPIRES %s": "⚠ UDLØBER %s", "RESETS": "NULSTILLINGER", diff --git a/internal/i18n/locales/de.json b/internal/i18n/locales/de.json index 77611e3..f74a4c6 100644 --- a/internal/i18n/locales/de.json +++ b/internal/i18n/locales/de.json @@ -1,4 +1,7 @@ { + "Unused allowance does not carry over or stack.": "Ungenutztes Kontingent wird nicht übertragen oder addiert.", + "Expiry information unavailable. No warning does not mean no expiry.": "Ablaufinformationen nicht verfügbar. Keine Warnung bedeutet nicht, dass nichts verfällt.", + "Expiry information incomplete. Other credits may expire sooner.": "Ablaufinformationen unvollständig. Andere Guthaben können früher verfallen.", "⚠ RESET EXPIRES IN %s": "⚠ RESET VERFÄLLT IN %s", "⚠ EXPIRES %s": "⚠ VERFÄLLT %s", "RESETS": "ZURÜCKSETZUNGEN", diff --git a/internal/i18n/locales/en-GB.json b/internal/i18n/locales/en-GB.json index 11ac3ad..fea836d 100644 --- a/internal/i18n/locales/en-GB.json +++ b/internal/i18n/locales/en-GB.json @@ -1,4 +1,7 @@ { + "Unused allowance does not carry over or stack.": "Unused allowance does not carry over or stack.", + "Expiry information unavailable. No warning does not mean no expiry.": "Expiry information unavailable. No warning does not mean no expiry.", + "Expiry information incomplete. Other credits may expire sooner.": "Expiry information incomplete. Other credits may expire sooner.", "⚠ RESET EXPIRES IN %s": "⚠ RESET EXPIRES IN %s", "⚠ EXPIRES %s": "⚠ EXPIRES %s", "RESETS": "RESETS", diff --git a/internal/i18n/locales/es.json b/internal/i18n/locales/es.json index 88e7e02..e2fa6b9 100644 --- a/internal/i18n/locales/es.json +++ b/internal/i18n/locales/es.json @@ -1,4 +1,7 @@ { + "Unused allowance does not carry over or stack.": "La cuota sin usar no se transfiere ni se acumula.", + "Expiry information unavailable. No warning does not mean no expiry.": "Información de caducidad no disponible. Sin aviso no significa sin caducidad.", + "Expiry information incomplete. Other credits may expire sooner.": "Información de caducidad incompleta. Otros créditos pueden caducar antes.", "⚠ RESET EXPIRES IN %s": "⚠ REINICIO CADUCA EN %s", "⚠ EXPIRES %s": "⚠ CADUCA %s", "RESETS": "REINICIOS", diff --git a/internal/i18n/locales/et.json b/internal/i18n/locales/et.json index f30990d..c48c2f5 100644 --- a/internal/i18n/locales/et.json +++ b/internal/i18n/locales/et.json @@ -1,4 +1,7 @@ { + "Unused allowance does not carry over or stack.": "Kasutamata kvooti ei kanta üle ega liideta.", + "Expiry information unavailable. No warning does not mean no expiry.": "Aegumise teave pole saadaval. Hoiatuse puudumine ei tähenda aegumise puudumist.", + "Expiry information incomplete. Other credits may expire sooner.": "Aegumise teave on puudulik. Muud krediidid võivad varem aeguda.", "⚠ RESET EXPIRES IN %s": "⚠ LÄHTESTUS AEGUB %s PÄRAST", "⚠ EXPIRES %s": "⚠ AEGUB %s", "RESETS": "LÄHTESTUSED", diff --git a/internal/i18n/locales/fi.json b/internal/i18n/locales/fi.json index bf119ac..ebf0057 100644 --- a/internal/i18n/locales/fi.json +++ b/internal/i18n/locales/fi.json @@ -1,4 +1,7 @@ { + "Unused allowance does not carry over or stack.": "Käyttämätön kiintiö ei siirry eikä kerry.", + "Expiry information unavailable. No warning does not mean no expiry.": "Vanhenemistietoja ei ole saatavilla. Varoituksen puuttuminen ei tarkoita, ettei mikään vanhene.", + "Expiry information incomplete. Other credits may expire sooner.": "Vanhenemistiedot ovat puutteelliset. Muut krediitit voivat vanhentua aiemmin.", "⚠ RESET EXPIRES IN %s": "⚠ NOLLAUS VANHENEE %s KULUTTUA", "⚠ EXPIRES %s": "⚠ VANHENEE %s", "RESETS": "NOLLAUKSET", diff --git a/internal/i18n/locales/fr.json b/internal/i18n/locales/fr.json index 5aa10ad..a7937c8 100644 --- a/internal/i18n/locales/fr.json +++ b/internal/i18n/locales/fr.json @@ -1,4 +1,7 @@ { + "Unused allowance does not carry over or stack.": "Le quota inutilisé n’est ni reporté ni cumulé.", + "Expiry information unavailable. No warning does not mean no expiry.": "Informations d’expiration indisponibles. L’absence d’alerte ne signifie pas l’absence d’expiration.", + "Expiry information incomplete. Other credits may expire sooner.": "Informations d’expiration incomplètes. D’autres crédits peuvent expirer plus tôt.", "⚠ RESET EXPIRES IN %s": "⚠ RÉINITIALISATION EXPIRE DANS %s", "⚠ EXPIRES %s": "⚠ EXPIRE %s", "RESETS": "RÉINITIALISATIONS", diff --git a/internal/i18n/locales/it.json b/internal/i18n/locales/it.json index 545e5e0..22df93c 100644 --- a/internal/i18n/locales/it.json +++ b/internal/i18n/locales/it.json @@ -1,4 +1,7 @@ { + "Unused allowance does not carry over or stack.": "La quota inutilizzata non viene riportata né cumulata.", + "Expiry information unavailable. No warning does not mean no expiry.": "Informazioni sulla scadenza non disponibili. Nessun avviso non significa nessuna scadenza.", + "Expiry information incomplete. Other credits may expire sooner.": "Informazioni sulla scadenza incomplete. Altri crediti potrebbero scadere prima.", "⚠ RESET EXPIRES IN %s": "⚠ RIPRISTINO SCADE TRA %s", "⚠ EXPIRES %s": "⚠ SCADE %s", "RESETS": "RIPRISTINI", diff --git a/internal/i18n/locales/ja.json b/internal/i18n/locales/ja.json index 9133e09..b4d47ad 100644 --- a/internal/i18n/locales/ja.json +++ b/internal/i18n/locales/ja.json @@ -1,4 +1,7 @@ { + "Unused allowance does not carry over or stack.": "未使用の利用枠は繰り越しも加算もされません。", + "Expiry information unavailable. No warning does not mean no expiry.": "有効期限情報を取得できません。警告がなくても期限がないとは限りません。", + "Expiry information incomplete. Other credits may expire sooner.": "有効期限情報が不完全です。他のクレジットが先に失効する可能性があります。", "⚠ RESET EXPIRES IN %s": "⚠ リセット期限まで %s", "⚠ EXPIRES %s": "⚠ 期限 %s", "RESETS": "リセット", diff --git a/internal/i18n/locales/nb.json b/internal/i18n/locales/nb.json index ca527da..d1427fb 100644 --- a/internal/i18n/locales/nb.json +++ b/internal/i18n/locales/nb.json @@ -1,4 +1,7 @@ { + "Unused allowance does not carry over or stack.": "Ubrukt kvote overføres ikke eller legges til.", + "Expiry information unavailable. No warning does not mean no expiry.": "Utløpsinformasjon er utilgjengelig. Ingen advarsel betyr ikke at ingenting utløper.", + "Expiry information incomplete. Other credits may expire sooner.": "Utløpsinformasjonen er ufullstendig. Andre kreditter kan utløpe tidligere.", "⚠ RESET EXPIRES IN %s": "⚠ NULLSTILLING UTLØPER OM %s", "⚠ EXPIRES %s": "⚠ UTLØPER %s", "RESETS": "NULLSTILLINGER", diff --git a/internal/i18n/locales/nl.json b/internal/i18n/locales/nl.json index 6d02c5b..350e8c1 100644 --- a/internal/i18n/locales/nl.json +++ b/internal/i18n/locales/nl.json @@ -1,4 +1,7 @@ { + "Unused allowance does not carry over or stack.": "Ongebruikte capaciteit wordt niet meegenomen of opgeteld.", + "Expiry information unavailable. No warning does not mean no expiry.": "Vervalinformatie niet beschikbaar. Geen waarschuwing betekent niet dat niets verloopt.", + "Expiry information incomplete. Other credits may expire sooner.": "Vervalinformatie onvolledig. Andere tegoeden kunnen eerder verlopen.", "⚠ RESET EXPIRES IN %s": "⚠ RESET VERLOOPT OVER %s", "⚠ EXPIRES %s": "⚠ VERLOOPT %s", "RESETS": "RESETS", diff --git a/internal/i18n/locales/pt-BR.json b/internal/i18n/locales/pt-BR.json index e6e54b5..a803edd 100644 --- a/internal/i18n/locales/pt-BR.json +++ b/internal/i18n/locales/pt-BR.json @@ -1,4 +1,7 @@ { + "Unused allowance does not carry over or stack.": "A cota não utilizada não é transferida nem acumulada.", + "Expiry information unavailable. No warning does not mean no expiry.": "Informações de validade indisponíveis. A ausência de aviso não significa ausência de vencimento.", + "Expiry information incomplete. Other credits may expire sooner.": "Informações de validade incompletas. Outros créditos podem vencer antes.", "⚠ RESET EXPIRES IN %s": "⚠ REDEFINIÇÃO EXPIRA EM %s", "⚠ EXPIRES %s": "⚠ EXPIRA %s", "RESETS": "REDEFINIÇÕES", diff --git a/internal/i18n/locales/pt-PT.json b/internal/i18n/locales/pt-PT.json index f8b8e9e..37fb13d 100644 --- a/internal/i18n/locales/pt-PT.json +++ b/internal/i18n/locales/pt-PT.json @@ -1,4 +1,7 @@ { + "Unused allowance does not carry over or stack.": "A quota não utilizada não é transferida nem acumulada.", + "Expiry information unavailable. No warning does not mean no expiry.": "Informações de validade indisponíveis. A ausência de aviso não significa ausência de expiração.", + "Expiry information incomplete. Other credits may expire sooner.": "Informações de validade incompletas. Outros créditos podem expirar mais cedo.", "⚠ RESET EXPIRES IN %s": "⚠ REPOSIÇÃO EXPIRA EM %s", "⚠ EXPIRES %s": "⚠ EXPIRA %s", "RESETS": "REPOSIÇÕES", diff --git a/internal/i18n/locales/ru.json b/internal/i18n/locales/ru.json index 6457f26..d4a23c9 100644 --- a/internal/i18n/locales/ru.json +++ b/internal/i18n/locales/ru.json @@ -1,4 +1,7 @@ { + "Unused allowance does not carry over or stack.": "Неиспользованный лимит не переносится и не суммируется.", + "Expiry information unavailable. No warning does not mean no expiry.": "Сведения о сроках недоступны. Отсутствие предупреждения не означает отсутствие срока действия.", + "Expiry information incomplete. Other credits may expire sooner.": "Сведения о сроках неполные. Другие кредиты могут истечь раньше.", "⚠ RESET EXPIRES IN %s": "⚠ СБРОС ИСТЕКАЕТ ЧЕРЕЗ %s", "⚠ EXPIRES %s": "⚠ ИСТЕКАЕТ %s", "RESETS": "СБРОСЫ", diff --git a/internal/i18n/locales/sv.json b/internal/i18n/locales/sv.json index eacc489..7dbbacd 100644 --- a/internal/i18n/locales/sv.json +++ b/internal/i18n/locales/sv.json @@ -1,4 +1,7 @@ { + "Unused allowance does not carry over or stack.": "Oanvänd kvot förs inte över eller läggs till.", + "Expiry information unavailable. No warning does not mean no expiry.": "Information om utgångsdatum saknas. Ingen varning betyder inte att inget löper ut.", + "Expiry information incomplete. Other credits may expire sooner.": "Information om utgångsdatum är ofullständig. Andra krediter kan löpa ut tidigare.", "⚠ RESET EXPIRES IN %s": "⚠ ÅTERSTÄLLNING UPPHÖR OM %s", "⚠ EXPIRES %s": "⚠ UPPHÖR %s", "RESETS": "ÅTERSTÄLLNINGAR", diff --git a/internal/i18n/locales/tr.json b/internal/i18n/locales/tr.json index 2409e6c..4ffcf2a 100644 --- a/internal/i18n/locales/tr.json +++ b/internal/i18n/locales/tr.json @@ -1,4 +1,7 @@ { + "Unused allowance does not carry over or stack.": "Kullanılmayan kota devredilmez veya birikmez.", + "Expiry information unavailable. No warning does not mean no expiry.": "Son kullanma bilgisi yok. Uyarı olmaması süre dolmayacağı anlamına gelmez.", + "Expiry information incomplete. Other credits may expire sooner.": "Son kullanma bilgisi eksik. Diğer kredilerin süresi daha erken dolabilir.", "⚠ RESET EXPIRES IN %s": "⚠ SIFIRLAMA %s İÇİNDE SONA ERER", "⚠ EXPIRES %s": "⚠ SONA ERER %s", "RESETS": "SIFIRLAMALAR", diff --git a/internal/i18n/locales/zh-Hans.json b/internal/i18n/locales/zh-Hans.json index dd361e1..72b453c 100644 --- a/internal/i18n/locales/zh-Hans.json +++ b/internal/i18n/locales/zh-Hans.json @@ -1,4 +1,7 @@ { + "Unused allowance does not carry over or stack.": "未使用的额度不会结转或叠加。", + "Expiry information unavailable. No warning does not mean no expiry.": "有效期信息不可用。没有警告不代表不会过期。", + "Expiry information incomplete. Other credits may expire sooner.": "有效期信息不完整。其他重置额度可能更早过期。", "⚠ RESET EXPIRES IN %s": "⚠ 重置将在 %s 后过期", "⚠ EXPIRES %s": "⚠ 过期 %s", "RESETS": "重置", diff --git a/internal/ui/english_snapshot_test.go b/internal/ui/english_snapshot_test.go index 1528d67..808ccf4 100644 --- a/internal/ui/english_snapshot_test.go +++ b/internal/ui/english_snapshot_test.go @@ -31,9 +31,9 @@ func TestEnglishPresentationSnapshot(t *testing.T) { } } got := fmt.Sprintf("%x", hash.Sum(nil)) - // Baseline intentionally updated for the additional Quota / Resets view; + // Baseline intentionally updated for the Resets expiry-data safety notice; // all themes, views and three terminal sizes are covered. - const want = "9f465544c26ed1b8f652c32d4a58c75c6981322a04ba95b7a045f75fbf092f49" + const want = "6f5ea02c5a933a13fbc23cad230c7684fca7b80de0c3e028e563b3b2e019161f" if got != want { t.Fatalf("English presentation changed: got %s, want %s", got, want) } diff --git a/internal/ui/quota_reset.go b/internal/ui/quota_reset.go index 4cab6f8..eef7fe8 100644 --- a/internal/ui/quota_reset.go +++ b/internal/ui/quota_reset.go @@ -263,6 +263,10 @@ func (m Model) pressQuotaReset() (tea.Model, tea.Cmd) { m.persistPreferences() m.resetConfirmUntil = time.Now().Add(10 * time.Second) m.resetNotice = i18n.Text("Use one reset? Refreshes eligible quota and changes the weekly reset schedule. Click CONFIRM; Esc cancels.") + m.resetNotice += " " + i18n.Text("Unused allowance does not carry over or stack.") + if notice := m.resetExpiryDataNotice(); notice != "" { + m.resetNotice += " " + notice + } if m.resetCreditID == "" { m.resetNotice += " Expiry order unavailable; backend chooses the credit." } else { @@ -342,14 +346,32 @@ func resetCreditExpiry(c codex.ResetCredit) string { return "Expires " + time.Unix(*c.ExpiresAt, 0).Local().Format("02 Jan 2006 15:04 MST") } +func (m Model) resetExpiryDataNotice() string { + summary := m.snapshot.RateLimitResetCredits + if summary != nil && summary.AvailableCount <= 0 { + return "" + } + credits := m.availableResetCredits() + if len(credits) == 0 { + return i18n.Text("Expiry information unavailable. No warning does not mean no expiry.") + } + if len(credits) < summary.AvailableCount { + return i18n.Text("Expiry information incomplete. Other credits may expire sooner.") + } + return "" +} + func (m Model) resetDetailLines(width int, colors palette) (result []string) { defer func() { result = strings.Split(ansi.Hardwrap(strings.Join(result, "\n"), max(width, 1), true), "\n") }() summary := m.snapshot.RateLimitResetCredits if summary == nil { - return []string{"Reset information unavailable."} + return []string{"Reset information unavailable.", m.resetExpiryDataNotice()} } lines := []string{fmt.Sprintf("AVAILABLE // %d", summary.AvailableCount)} credits := m.availableResetCredits() + if notice := m.resetExpiryDataNotice(); notice != "" { + lines = append(lines, colors.label().Foreground(colors.warning).Render(notice)) + } if m.resetExpiringSoon() { lines = append(lines, colors.label().Foreground(colors.warning).Render(fmt.Sprintf("EXPIRING SOON // within %d hours", m.resetWarningHours))) } @@ -357,7 +379,7 @@ func (m Model) resetDetailLines(width int, colors palette) (result []string) { return append(lines, "No resets available.") } if len(credits) == 0 { - return append(lines, "Expiry details unavailable. Backend selects the next credit.") + return append(lines, "Backend selects the next credit; expiry order is unknown.") } lines = append(lines, fmt.Sprintf("Showing %d of %d available resets. Earliest known expiry first.", len(credits), summary.AvailableCount)) for index, credit := range credits { diff --git a/internal/ui/reset_credits_test.go b/internal/ui/reset_credits_test.go index eb4cf77..3c5c2d4 100644 --- a/internal/ui/reset_credits_test.go +++ b/internal/ui/reset_credits_test.go @@ -125,7 +125,7 @@ func TestResetConfirmationRejectsStaleData(t *testing.T) { func TestResetDetailsMissingAndPartial(t *testing.T) { m, _ := resetModel() colors := paletteFor(m.theme) - if !strings.Contains(strings.Join(m.resetDetailLines(100, colors), "\n"), "Expiry details unavailable") { + if !strings.Contains(strings.Join(m.resetDetailLines(100, colors), "\n"), "Expiry information unavailable") { t.Fatal("missing details presented as no resets") } m.snapshot.RateLimitResetCredits.Credits = []codex.ResetCredit{credit("one", 24*time.Hour)} @@ -135,6 +135,55 @@ func TestResetDetailsMissingAndPartial(t *testing.T) { } } +func TestResetSafetyMessages(t *testing.T) { + for _, mode := range []string{"missing", "partial", "complete", "non-expiring", "none"} { + t.Run(mode, func(t *testing.T) { + m, _ := resetModel() + m.fetcher = &specificResetFake{} + if mode != "missing" { + m.snapshot.RateLimitResetCredits.Credits = []codex.ResetCredit{credit("known", time.Hour)} + } + if mode == "complete" || mode == "non-expiring" { + m.snapshot.RateLimitResetCredits.AvailableCount = 1 + } + if mode == "non-expiring" { + m.snapshot.RateLimitResetCredits.Credits[0].ExpiresAt = nil + } + if mode == "none" { + m.snapshot.RateLimitResetCredits.AvailableCount = 0 + } + notice := m.resetExpiryDataNotice() + if mode == "missing" && !strings.Contains(notice, "No warning does not mean no expiry") { + t.Fatal("missing data implies safety") + } + if mode == "partial" && !strings.Contains(notice, "Other credits may expire sooner") { + t.Fatal("partial inventory implies global earliest expiry") + } + if mode != "missing" && mode != "partial" && notice != "" { + t.Fatal("invented missing data") + } + body := strings.Join(m.resetDetailLines(500, paletteFor(m.theme)), "\n") + if notice != "" && !strings.Contains(body, notice) { + t.Fatal("inventory omitted disclosure") + } + if mode == "non-expiring" && !strings.Contains(body, "Does not expire") { + t.Fatal("non-expiring credit confused with missing information") + } + if mode == "none" { + return + } + u, cmd := m.pressQuotaReset() + m = u.(Model) + if cmd != nil || !strings.Contains(m.resetNotice, "Unused allowance does not carry over or stack") || !strings.Contains(m.resetNotice, "weekly reset schedule") { + t.Fatal("confirmation omitted trade-off") + } + if notice != "" && !strings.Contains(m.resetNotice, notice) { + t.Fatal("confirmation omitted disclosure") + } + }) + } +} + func TestResetsResponsiveGeometryAndScroll(t *testing.T) { for _, width := range []int{28, 40, 80, 140} { m, _ := resetModel() diff --git a/intro-post.md b/intro-post.md index e4f95b0..e085f56 100644 --- a/intro-post.md +++ b/intro-post.md @@ -63,6 +63,8 @@ Ordinary Codex CLI sessions work out of the box. For the best Sessions feedback, Use `--reset-warning-hours 24` to warn one day ahead, or `--reset-warning-hours 168` for a week (handy for testing known later expiries). The default is 72 hours; `0` disables expiry warnings while retaining the consumption-based reset button. +Expiry warnings are reminders to review, not instructions to reset immediately. Confirmation warns that unused allowance does not carry over or stack and that the weekly schedule changes. Missing or incomplete expiry information is explicitly flagged: no warning is not proof that nothing expires soon, and undisclosed credits may expire sooner. + ## Make it yours Choose Hacker, Rust, Blue Steel, Ultraviolet, or Nightshade with `t`. UK English remains the default and retains the original presentation. Set `CODEXOMETER_LANG` to opt into Dutch (`nl`), German (`de`), French (`fr`), Italian (`it`), Spanish (`es`), Russian (`ru`), Japanese (`ja`), Simplified Chinese (`zh-Hans`), Swedish (`sv`), Norwegian Bokmål (`nb`, also `no`), Turkish (`tr`), Estonian (`et`), Finnish (`fi`), Brazilian Portuguese (`pt-BR`), European Portuguese (`pt-PT`), or Danish (`da`): From 7a3e7f30d681d35beaddad4de58eb3d38bb16bea Mon Sep 17 00:00:00 2001 From: merefield Date: Fri, 11 Sep 2026 13:54:18 +0100 Subject: [PATCH 4/4] FIX: preserve unknown reset expiry and localise warnings --- README.md | 5 +++- internal/codex/reset_credits_test.go | 38 +++++++++++++++++++++++++ internal/codex/types.go | 42 ++++++++++++++++++++++++++++ internal/i18n/locales/da.json | 3 ++ internal/i18n/locales/de.json | 3 ++ internal/i18n/locales/en-GB.json | 3 ++ internal/i18n/locales/es.json | 3 ++ internal/i18n/locales/et.json | 3 ++ internal/i18n/locales/fi.json | 3 ++ internal/i18n/locales/fr.json | 3 ++ internal/i18n/locales/it.json | 3 ++ internal/i18n/locales/ja.json | 3 ++ internal/i18n/locales/nb.json | 3 ++ internal/i18n/locales/nl.json | 3 ++ internal/i18n/locales/pt-BR.json | 3 ++ internal/i18n/locales/pt-PT.json | 3 ++ internal/i18n/locales/ru.json | 3 ++ internal/i18n/locales/sv.json | 3 ++ internal/i18n/locales/tr.json | 3 ++ internal/i18n/locales/zh-Hans.json | 3 ++ internal/ui/english_snapshot_test.go | 2 +- internal/ui/localisation_test.go | 1 + internal/ui/quota_reset.go | 32 +++++++++++++++------ internal/ui/reset_credits_test.go | 41 +++++++++++++++++++++++++++ internal/ui/reset_warning_test.go | 23 +++++++++++++++ 25 files changed, 224 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index f91c0dc..b4e7147 100644 --- a/README.md +++ b/README.md @@ -882,7 +882,10 @@ how many of the available credits have usable details; earliest expiry means **earliest known**, not a guarantee about undisclosed credits. If only a count is available, expiry is unknown and the backend chooses the credit; its default selection order is not guaranteed by the public protocol. A supplied null expiry -means “does not expire”, not “unknown”. Reset-credit expiry is separate from the +means “does not expire”; an omitted expiry field means “unknown”. Credits with +unknown expiry remain visible with a disclosure, but are not selected as the +earliest known expiry. With no comparable expiries, the backend selects the credit. +Reset-credit expiry is separate from the automatic quota-window reset date. ### Usage: account token history diff --git a/internal/codex/reset_credits_test.go b/internal/codex/reset_credits_test.go index dca7251..6cd9b96 100644 --- a/internal/codex/reset_credits_test.go +++ b/internal/codex/reset_credits_test.go @@ -28,3 +28,41 @@ func TestDecodeResetCreditDetails(t *testing.T) { t.Fatal("count-only compatibility lost") } } + +func TestResetCreditExpiryPresenceRoundTrip(t *testing.T) { + for _, input := range []string{`{"id":"credit"}`, `{"id":"credit","expiresAt":null}`, `{"id":"credit","expiresAt":1800000000}`} { + var raw map[string]json.RawMessage + if err := json.Unmarshal([]byte(input), &raw); err != nil { + t.Fatal(err) + } + var credit ResetCredit + // Decode again into an existing value to catch retained presence flags. + for range 2 { + if err := json.Unmarshal([]byte(input), &credit); err != nil { + t.Fatal(err) + } + _, present := raw["expiresAt"] + if credit.HasKnownExpiry() != present { + t.Fatalf("%s: wrong presence", input) + } + encoded, err := json.Marshal(credit) + if err != nil { + t.Fatal(err) + } + var output map[string]json.RawMessage + if err := json.Unmarshal(encoded, &output); err != nil { + t.Fatal(err) + } + if string(output["expiresAt"]) != string(raw["expiresAt"]) { + t.Fatalf("%s round trip became %s", input, encoded) + } + if err := json.Unmarshal([]byte(`{"expiresAt":123}`), &credit); err != nil { + t.Fatal(err) + } + } + } + var credit ResetCredit + if err := json.Unmarshal([]byte(`{"expiresAt":"invalid"}`), &credit); err == nil { + t.Fatal("accepted malformed expiry") + } +} diff --git a/internal/codex/types.go b/internal/codex/types.go index 42a3e27..69b76a6 100644 --- a/internal/codex/types.go +++ b/internal/codex/types.go @@ -1,6 +1,7 @@ package codex import ( + "encoding/json" "fmt" "sort" "strings" @@ -57,10 +58,51 @@ type ResetCredit struct { Status string `json:"status"` GrantedAt int64 `json:"grantedAt"` ExpiresAt *int64 `json:"expiresAt"` + ExpiryKnown bool `json:"-"` Title string `json:"title"` Description string `json:"description"` } +// HasKnownExpiry distinguishes a disclosed non-expiring credit (explicit null) +// from one for which the backend omitted expiry information altogether. +func (c ResetCredit) HasKnownExpiry() bool { return c.ExpiresAt != nil || c.ExpiryKnown } + +func (c *ResetCredit) UnmarshalJSON(data []byte) error { + type plain ResetCredit + var wire struct { + plain + ExpiresAt json.RawMessage `json:"expiresAt"` + } + if err := json.Unmarshal(data, &wire); err != nil { + return err + } + decoded := ResetCredit(wire.plain) + decoded.ExpiryKnown = len(wire.ExpiresAt) > 0 + if decoded.ExpiryKnown { + if err := json.Unmarshal(wire.ExpiresAt, &decoded.ExpiresAt); err != nil { + return err + } + } + *c = decoded + return nil +} + +func (c ResetCredit) MarshalJSON() ([]byte, error) { + type plain ResetCredit + var expiry json.RawMessage + if c.HasKnownExpiry() { + var err error + expiry, err = json.Marshal(c.ExpiresAt) + if err != nil { + return nil, err + } + } + return json.Marshal(struct { + plain + ExpiresAt json.RawMessage `json:"expiresAt,omitempty"` + }{plain(c), expiry}) +} + type Meter struct { Bucket string LimitID string diff --git a/internal/i18n/locales/da.json b/internal/i18n/locales/da.json index bbab707..a901d45 100644 --- a/internal/i18n/locales/da.json +++ b/internal/i18n/locales/da.json @@ -1,4 +1,7 @@ { + "Expiry order unavailable; backend chooses the credit.": "Udløbsrækkefølgen er ukendt; serveren vælger kreditten.", + "EXPIRING SOON // within %d hours": "UDLØBER SNART // inden for %d timer", + "Expiry information unavailable.": "Udløbsoplysninger er ikke tilgængelige.", "Unused allowance does not carry over or stack.": "Ubrugt kvote overføres ikke eller lægges til.", "Expiry information unavailable. No warning does not mean no expiry.": "Udløbsoplysninger er ikke tilgængelige. Ingen advarsel betyder ikke, at intet udløber.", "Expiry information incomplete. Other credits may expire sooner.": "Udløbsoplysningerne er ufuldstændige. Andre kreditter kan udløbe tidligere.", diff --git a/internal/i18n/locales/de.json b/internal/i18n/locales/de.json index f74a4c6..6c3f5cc 100644 --- a/internal/i18n/locales/de.json +++ b/internal/i18n/locales/de.json @@ -1,4 +1,7 @@ { + "Expiry order unavailable; backend chooses the credit.": "Ablaufreihenfolge unbekannt; das Backend wählt das Guthaben.", + "EXPIRING SOON // within %d hours": "BALD ABGELAUFEN // innerhalb von %d Stunden", + "Expiry information unavailable.": "Ablaufinformationen nicht verfügbar.", "Unused allowance does not carry over or stack.": "Ungenutztes Kontingent wird nicht übertragen oder addiert.", "Expiry information unavailable. No warning does not mean no expiry.": "Ablaufinformationen nicht verfügbar. Keine Warnung bedeutet nicht, dass nichts verfällt.", "Expiry information incomplete. Other credits may expire sooner.": "Ablaufinformationen unvollständig. Andere Guthaben können früher verfallen.", diff --git a/internal/i18n/locales/en-GB.json b/internal/i18n/locales/en-GB.json index fea836d..2c8497c 100644 --- a/internal/i18n/locales/en-GB.json +++ b/internal/i18n/locales/en-GB.json @@ -1,4 +1,7 @@ { + "Expiry order unavailable; backend chooses the credit.": "Expiry order unavailable; backend chooses the credit.", + "EXPIRING SOON // within %d hours": "EXPIRING SOON // within %d hours", + "Expiry information unavailable.": "Expiry information unavailable.", "Unused allowance does not carry over or stack.": "Unused allowance does not carry over or stack.", "Expiry information unavailable. No warning does not mean no expiry.": "Expiry information unavailable. No warning does not mean no expiry.", "Expiry information incomplete. Other credits may expire sooner.": "Expiry information incomplete. Other credits may expire sooner.", diff --git a/internal/i18n/locales/es.json b/internal/i18n/locales/es.json index e2fa6b9..0e73b01 100644 --- a/internal/i18n/locales/es.json +++ b/internal/i18n/locales/es.json @@ -1,4 +1,7 @@ { + "Expiry order unavailable; backend chooses the credit.": "Orden de caducidad desconocido; el servidor elige el crédito.", + "EXPIRING SOON // within %d hours": "CADUCA PRONTO // en %d horas", + "Expiry information unavailable.": "Información de caducidad no disponible.", "Unused allowance does not carry over or stack.": "La cuota sin usar no se transfiere ni se acumula.", "Expiry information unavailable. No warning does not mean no expiry.": "Información de caducidad no disponible. Sin aviso no significa sin caducidad.", "Expiry information incomplete. Other credits may expire sooner.": "Información de caducidad incompleta. Otros créditos pueden caducar antes.", diff --git a/internal/i18n/locales/et.json b/internal/i18n/locales/et.json index c48c2f5..609e511 100644 --- a/internal/i18n/locales/et.json +++ b/internal/i18n/locales/et.json @@ -1,4 +1,7 @@ { + "Expiry order unavailable; backend chooses the credit.": "Aegumise järjekord pole teada; krediidi valib server.", + "EXPIRING SOON // within %d hours": "AEGUB PEAGI // %d tunni jooksul", + "Expiry information unavailable.": "Aegumise teave pole saadaval.", "Unused allowance does not carry over or stack.": "Kasutamata kvooti ei kanta üle ega liideta.", "Expiry information unavailable. No warning does not mean no expiry.": "Aegumise teave pole saadaval. Hoiatuse puudumine ei tähenda aegumise puudumist.", "Expiry information incomplete. Other credits may expire sooner.": "Aegumise teave on puudulik. Muud krediidid võivad varem aeguda.", diff --git a/internal/i18n/locales/fi.json b/internal/i18n/locales/fi.json index ebf0057..72d3a25 100644 --- a/internal/i18n/locales/fi.json +++ b/internal/i18n/locales/fi.json @@ -1,4 +1,7 @@ { + "Expiry order unavailable; backend chooses the credit.": "Vanhenemisjärjestys ei ole tiedossa; palvelin valitsee krediitin.", + "EXPIRING SOON // within %d hours": "VANHENEE PIAN // %d tunnin kuluessa", + "Expiry information unavailable.": "Vanhenemistietoja ei ole saatavilla.", "Unused allowance does not carry over or stack.": "Käyttämätön kiintiö ei siirry eikä kerry.", "Expiry information unavailable. No warning does not mean no expiry.": "Vanhenemistietoja ei ole saatavilla. Varoituksen puuttuminen ei tarkoita, ettei mikään vanhene.", "Expiry information incomplete. Other credits may expire sooner.": "Vanhenemistiedot ovat puutteelliset. Muut krediitit voivat vanhentua aiemmin.", diff --git a/internal/i18n/locales/fr.json b/internal/i18n/locales/fr.json index a7937c8..7e10858 100644 --- a/internal/i18n/locales/fr.json +++ b/internal/i18n/locales/fr.json @@ -1,4 +1,7 @@ { + "Expiry order unavailable; backend chooses the credit.": "Ordre d’expiration inconnu ; le serveur choisit le crédit.", + "EXPIRING SOON // within %d hours": "EXPIRE BIENTÔT // sous %d heures", + "Expiry information unavailable.": "Informations d’expiration indisponibles.", "Unused allowance does not carry over or stack.": "Le quota inutilisé n’est ni reporté ni cumulé.", "Expiry information unavailable. No warning does not mean no expiry.": "Informations d’expiration indisponibles. L’absence d’alerte ne signifie pas l’absence d’expiration.", "Expiry information incomplete. Other credits may expire sooner.": "Informations d’expiration incomplètes. D’autres crédits peuvent expirer plus tôt.", diff --git a/internal/i18n/locales/it.json b/internal/i18n/locales/it.json index 22df93c..8c35e5b 100644 --- a/internal/i18n/locales/it.json +++ b/internal/i18n/locales/it.json @@ -1,4 +1,7 @@ { + "Expiry order unavailable; backend chooses the credit.": "Ordine di scadenza sconosciuto; il backend sceglie il credito.", + "EXPIRING SOON // within %d hours": "IN SCADENZA // entro %d ore", + "Expiry information unavailable.": "Informazioni sulla scadenza non disponibili.", "Unused allowance does not carry over or stack.": "La quota inutilizzata non viene riportata né cumulata.", "Expiry information unavailable. No warning does not mean no expiry.": "Informazioni sulla scadenza non disponibili. Nessun avviso non significa nessuna scadenza.", "Expiry information incomplete. Other credits may expire sooner.": "Informazioni sulla scadenza incomplete. Altri crediti potrebbero scadere prima.", diff --git a/internal/i18n/locales/ja.json b/internal/i18n/locales/ja.json index b4d47ad..23f3087 100644 --- a/internal/i18n/locales/ja.json +++ b/internal/i18n/locales/ja.json @@ -1,4 +1,7 @@ { + "Expiry order unavailable; backend chooses the credit.": "有効期限の順序は不明です。バックエンドがクレジットを選択します。", + "EXPIRING SOON // within %d hours": "まもなく期限切れ // %d 時間以内", + "Expiry information unavailable.": "有効期限情報を取得できません。", "Unused allowance does not carry over or stack.": "未使用の利用枠は繰り越しも加算もされません。", "Expiry information unavailable. No warning does not mean no expiry.": "有効期限情報を取得できません。警告がなくても期限がないとは限りません。", "Expiry information incomplete. Other credits may expire sooner.": "有効期限情報が不完全です。他のクレジットが先に失効する可能性があります。", diff --git a/internal/i18n/locales/nb.json b/internal/i18n/locales/nb.json index d1427fb..6d649c2 100644 --- a/internal/i18n/locales/nb.json +++ b/internal/i18n/locales/nb.json @@ -1,4 +1,7 @@ { + "Expiry order unavailable; backend chooses the credit.": "Utløpsrekkefølgen er ukjent; serveren velger kreditten.", + "EXPIRING SOON // within %d hours": "UTLØPER SNART // innen %d timer", + "Expiry information unavailable.": "Utløpsinformasjon er utilgjengelig.", "Unused allowance does not carry over or stack.": "Ubrukt kvote overføres ikke eller legges til.", "Expiry information unavailable. No warning does not mean no expiry.": "Utløpsinformasjon er utilgjengelig. Ingen advarsel betyr ikke at ingenting utløper.", "Expiry information incomplete. Other credits may expire sooner.": "Utløpsinformasjonen er ufullstendig. Andre kreditter kan utløpe tidligere.", diff --git a/internal/i18n/locales/nl.json b/internal/i18n/locales/nl.json index 350e8c1..e12fa15 100644 --- a/internal/i18n/locales/nl.json +++ b/internal/i18n/locales/nl.json @@ -1,4 +1,7 @@ { + "Expiry order unavailable; backend chooses the credit.": "Vervolgorde onbekend; de backend kiest het tegoed.", + "EXPIRING SOON // within %d hours": "VERLOOPT BINNENKORT // binnen %d uur", + "Expiry information unavailable.": "Vervalinformatie niet beschikbaar.", "Unused allowance does not carry over or stack.": "Ongebruikte capaciteit wordt niet meegenomen of opgeteld.", "Expiry information unavailable. No warning does not mean no expiry.": "Vervalinformatie niet beschikbaar. Geen waarschuwing betekent niet dat niets verloopt.", "Expiry information incomplete. Other credits may expire sooner.": "Vervalinformatie onvolledig. Andere tegoeden kunnen eerder verlopen.", diff --git a/internal/i18n/locales/pt-BR.json b/internal/i18n/locales/pt-BR.json index a803edd..6672fbd 100644 --- a/internal/i18n/locales/pt-BR.json +++ b/internal/i18n/locales/pt-BR.json @@ -1,4 +1,7 @@ { + "Expiry order unavailable; backend chooses the credit.": "Ordem de vencimento desconhecida; o servidor escolhe o crédito.", + "EXPIRING SOON // within %d hours": "VENCE EM BREVE // em %d horas", + "Expiry information unavailable.": "Informações de validade indisponíveis.", "Unused allowance does not carry over or stack.": "A cota não utilizada não é transferida nem acumulada.", "Expiry information unavailable. No warning does not mean no expiry.": "Informações de validade indisponíveis. A ausência de aviso não significa ausência de vencimento.", "Expiry information incomplete. Other credits may expire sooner.": "Informações de validade incompletas. Outros créditos podem vencer antes.", diff --git a/internal/i18n/locales/pt-PT.json b/internal/i18n/locales/pt-PT.json index 37fb13d..f0de615 100644 --- a/internal/i18n/locales/pt-PT.json +++ b/internal/i18n/locales/pt-PT.json @@ -1,4 +1,7 @@ { + "Expiry order unavailable; backend chooses the credit.": "Ordem de expiração desconhecida; o servidor escolhe o crédito.", + "EXPIRING SOON // within %d hours": "EXPIRA EM BREVE // dentro de %d horas", + "Expiry information unavailable.": "Informações de validade indisponíveis.", "Unused allowance does not carry over or stack.": "A quota não utilizada não é transferida nem acumulada.", "Expiry information unavailable. No warning does not mean no expiry.": "Informações de validade indisponíveis. A ausência de aviso não significa ausência de expiração.", "Expiry information incomplete. Other credits may expire sooner.": "Informações de validade incompletas. Outros créditos podem expirar mais cedo.", diff --git a/internal/i18n/locales/ru.json b/internal/i18n/locales/ru.json index d4a23c9..68c561d 100644 --- a/internal/i18n/locales/ru.json +++ b/internal/i18n/locales/ru.json @@ -1,4 +1,7 @@ { + "Expiry order unavailable; backend chooses the credit.": "Порядок истечения неизвестен; кредит выбирает сервер.", + "EXPIRING SOON // within %d hours": "СКОРО ИСТЕЧЁТ // в течение %d часов", + "Expiry information unavailable.": "Сведения о сроках недоступны.", "Unused allowance does not carry over or stack.": "Неиспользованный лимит не переносится и не суммируется.", "Expiry information unavailable. No warning does not mean no expiry.": "Сведения о сроках недоступны. Отсутствие предупреждения не означает отсутствие срока действия.", "Expiry information incomplete. Other credits may expire sooner.": "Сведения о сроках неполные. Другие кредиты могут истечь раньше.", diff --git a/internal/i18n/locales/sv.json b/internal/i18n/locales/sv.json index 7dbbacd..2403216 100644 --- a/internal/i18n/locales/sv.json +++ b/internal/i18n/locales/sv.json @@ -1,4 +1,7 @@ { + "Expiry order unavailable; backend chooses the credit.": "Utgångsordningen är okänd; servern väljer krediten.", + "EXPIRING SOON // within %d hours": "LÖPER SNART UT // inom %d timmar", + "Expiry information unavailable.": "Information om utgångsdatum saknas.", "Unused allowance does not carry over or stack.": "Oanvänd kvot förs inte över eller läggs till.", "Expiry information unavailable. No warning does not mean no expiry.": "Information om utgångsdatum saknas. Ingen varning betyder inte att inget löper ut.", "Expiry information incomplete. Other credits may expire sooner.": "Information om utgångsdatum är ofullständig. Andra krediter kan löpa ut tidigare.", diff --git a/internal/i18n/locales/tr.json b/internal/i18n/locales/tr.json index 4ffcf2a..d87a901 100644 --- a/internal/i18n/locales/tr.json +++ b/internal/i18n/locales/tr.json @@ -1,4 +1,7 @@ { + "Expiry order unavailable; backend chooses the credit.": "Sona erme sırası bilinmiyor; krediyi sunucu seçer.", + "EXPIRING SOON // within %d hours": "YAKINDA SONA ERİYOR // %d saat içinde", + "Expiry information unavailable.": "Son kullanma bilgisi yok.", "Unused allowance does not carry over or stack.": "Kullanılmayan kota devredilmez veya birikmez.", "Expiry information unavailable. No warning does not mean no expiry.": "Son kullanma bilgisi yok. Uyarı olmaması süre dolmayacağı anlamına gelmez.", "Expiry information incomplete. Other credits may expire sooner.": "Son kullanma bilgisi eksik. Diğer kredilerin süresi daha erken dolabilir.", diff --git a/internal/i18n/locales/zh-Hans.json b/internal/i18n/locales/zh-Hans.json index 72b453c..f5e2dea 100644 --- a/internal/i18n/locales/zh-Hans.json +++ b/internal/i18n/locales/zh-Hans.json @@ -1,4 +1,7 @@ { + "Expiry order unavailable; backend chooses the credit.": "有效期顺序未知;由后端选择重置额度。", + "EXPIRING SOON // within %d hours": "即将过期 // %d 小时内", + "Expiry information unavailable.": "有效期信息不可用。", "Unused allowance does not carry over or stack.": "未使用的额度不会结转或叠加。", "Expiry information unavailable. No warning does not mean no expiry.": "有效期信息不可用。没有警告不代表不会过期。", "Expiry information incomplete. Other credits may expire sooner.": "有效期信息不完整。其他重置额度可能更早过期。", diff --git a/internal/ui/english_snapshot_test.go b/internal/ui/english_snapshot_test.go index 808ccf4..1a1b19b 100644 --- a/internal/ui/english_snapshot_test.go +++ b/internal/ui/english_snapshot_test.go @@ -33,7 +33,7 @@ func TestEnglishPresentationSnapshot(t *testing.T) { got := fmt.Sprintf("%x", hash.Sum(nil)) // Baseline intentionally updated for the Resets expiry-data safety notice; // all themes, views and three terminal sizes are covered. - const want = "6f5ea02c5a933a13fbc23cad230c7684fca7b80de0c3e028e563b3b2e019161f" + const want = "411704f1771a2822de902584930a32a778a44b97d7e0d5b7fb4870d77493a0fc" if got != want { t.Fatalf("English presentation changed: got %s, want %s", got, want) } diff --git a/internal/ui/localisation_test.go b/internal/ui/localisation_test.go index 714b291..959f699 100644 --- a/internal/ui/localisation_test.go +++ b/internal/ui/localisation_test.go @@ -56,6 +56,7 @@ func TestLocalisedScreensHelper(t *testing.T) { t.Run("monitor_detail_sections", TestMonitorDetailStructuredSections) t.Run("tab_click_surfaces", TestEveryRenderedTabCellIsClickableAcrossWidths) t.Run("reset_warning_surfaces", TestResetWarningClickSurfaces) + t.Run("reset_warning_language", TestResetWarningsUseLocale) t.Run("header_click_surfaces", TestHeaderClickTargets) t.Run("detail_activity", TestDetailSentWaveLifecycle) t.Run("session_context_activity", TestMainSessionContextDots) diff --git a/internal/ui/quota_reset.go b/internal/ui/quota_reset.go index eef7fe8..b4f502d 100644 --- a/internal/ui/quota_reset.go +++ b/internal/ui/quota_reset.go @@ -48,6 +48,12 @@ func (m Model) availableResetCredits() []codex.ResetCredit { } } slices.SortStableFunc(credits, func(a, b codex.ResetCredit) int { + if a.HasKnownExpiry() != b.HasKnownExpiry() { + if a.HasKnownExpiry() { + return -1 + } + return 1 + } if a.ExpiresAt == nil && b.ExpiresAt == nil { return strings.Compare(a.ID, b.ID) } @@ -252,7 +258,7 @@ func (m Model) pressQuotaReset() (tea.Model, tea.Cmd) { if m.resetKey == "" { m.resetAccount = m.snapshot.AccountFingerprint m.resetCreditID = "" - if credits := m.availableResetCredits(); len(credits) > 0 { + if credits := m.availableResetCredits(); len(credits) > 0 && credits[0].HasKnownExpiry() { if _, ok := m.fetcher.(resetCreditConsumer); ok { m.resetCreditID = credits[0].ID } @@ -268,7 +274,7 @@ func (m Model) pressQuotaReset() (tea.Model, tea.Cmd) { m.resetNotice += " " + notice } if m.resetCreditID == "" { - m.resetNotice += " Expiry order unavailable; backend chooses the credit." + m.resetNotice += " " + i18n.Text("Expiry order unavailable; backend chooses the credit.") } else { for _, credit := range m.availableResetCredits() { if credit.ID == m.resetCreditID { @@ -296,7 +302,7 @@ func (m Model) pressQuotaReset() (tea.Model, tea.Cmd) { if m.resetCreditID != "" { found := false for _, credit := range m.availableResetCredits() { - if credit.ID == m.resetCreditID { + if credit.ID == m.resetCreditID && credit.HasKnownExpiry() { found = true break } @@ -340,6 +346,9 @@ func resetCreditTitle(c codex.ResetCredit) string { } func resetCreditExpiry(c codex.ResetCredit) string { + if !c.HasKnownExpiry() { + return i18n.Text("Expiry information unavailable.") + } if c.ExpiresAt == nil { return "Does not expire" } @@ -351,11 +360,16 @@ func (m Model) resetExpiryDataNotice() string { if summary != nil && summary.AvailableCount <= 0 { return "" } - credits := m.availableResetCredits() - if len(credits) == 0 { + known := 0 + for _, credit := range m.availableResetCredits() { + if credit.HasKnownExpiry() { + known++ + } + } + if known == 0 { return i18n.Text("Expiry information unavailable. No warning does not mean no expiry.") } - if len(credits) < summary.AvailableCount { + if known < summary.AvailableCount { return i18n.Text("Expiry information incomplete. Other credits may expire sooner.") } return "" @@ -373,20 +387,20 @@ func (m Model) resetDetailLines(width int, colors palette) (result []string) { lines = append(lines, colors.label().Foreground(colors.warning).Render(notice)) } if m.resetExpiringSoon() { - lines = append(lines, colors.label().Foreground(colors.warning).Render(fmt.Sprintf("EXPIRING SOON // within %d hours", m.resetWarningHours))) + lines = append(lines, colors.label().Foreground(colors.warning).Render(i18n.Format("EXPIRING SOON // within %d hours", m.resetWarningHours))) } if summary.AvailableCount == 0 { return append(lines, "No resets available.") } if len(credits) == 0 { - return append(lines, "Backend selects the next credit; expiry order is unknown.") + return append(lines, i18n.Text("Expiry order unavailable; backend chooses the credit.")) } lines = append(lines, fmt.Sprintf("Showing %d of %d available resets. Earliest known expiry first.", len(credits), summary.AvailableCount)) for index, credit := range credits { title := fmt.Sprintf("%d // %s", index+1, resetCreditTitle(credit)) if credit.ID == m.resetCreditID && (!m.resetConfirmUntil.IsZero() || m.resetKey != "") { title += " // SELECTED" - } else if index == 0 { + } else if index == 0 && credit.HasKnownExpiry() { title += " // NEXT" } lines = append(lines, "", colors.label().Render(title), resetCreditExpiry(credit)) diff --git a/internal/ui/reset_credits_test.go b/internal/ui/reset_credits_test.go index 3c5c2d4..5952a27 100644 --- a/internal/ui/reset_credits_test.go +++ b/internal/ui/reset_credits_test.go @@ -2,6 +2,7 @@ package ui import ( "context" + "encoding/json" "strings" "testing" "time" @@ -16,6 +17,44 @@ type specificResetFake struct { ids []string } +func TestUndisclosedResetExpiry(t *testing.T) { + m, _ := resetModel() + m.fetcher = &specificResetFake{} + var unknown codex.ResetCredit + if err := json.Unmarshal([]byte(`{"id":"unknown","status":"available","resetType":"codexRateLimits"}`), &unknown); err != nil { + t.Fatal(err) + } + m.snapshot.RateLimitResetCredits.AvailableCount = 1 + m.snapshot.RateLimitResetCredits.Credits = []codex.ResetCredit{unknown} + if resetCreditExpiry(unknown) == "Does not expire" || !strings.Contains(m.resetExpiryDataNotice(), "unavailable") { + t.Fatal("omitted expiry treated as non-expiring") + } + u, cmd := m.pressQuotaReset() + if cmd != nil || u.(Model).resetCreditID != "" { + t.Fatal("unknown expiry selected as known earliest") + } + forever := credit("forever", time.Hour) + forever.ExpiresAt, forever.ExpiryKnown = nil, true + m.snapshot.RateLimitResetCredits.AvailableCount = 3 + m.snapshot.RateLimitResetCredits.Credits = []codex.ResetCredit{unknown, forever, credit("soon", time.Hour)} + ordered := m.availableResetCredits() + if ordered[0].ID != "soon" || ordered[1].ID != "forever" || ordered[2].ID != "unknown" || !m.resetExpiringSoon() { + t.Fatal("unknown expiry displaced known expiring credit") + } + if !strings.Contains(m.resetExpiryDataNotice(), "incomplete") { + t.Fatal("unknown expiry omitted from coverage warning") + } + u, _ = m.pressQuotaReset() + m = u.(Model) + if m.resetCreditID != "soon" { + t.Fatal("did not choose known soonest expiry") + } + m.snapshot.RateLimitResetCredits.Credits[2].ExpiresAt = nil // refreshed source no longer discloses the selected expiry + if _, cmd := m.pressQuotaReset(); cmd != nil { + t.Fatal("submitted after selected expiry became unknown") + } +} + func (f *specificResetFake) ConsumeResetCredit(ctx context.Context, key, account, id string) (string, error) { f.ids = append(f.ids, id) return f.ConsumeReset(ctx, key, account) @@ -32,6 +71,7 @@ func TestResetCreditOrderingAndWarning(t *testing.T) { m.snapshot.RateLimitResetCredits.AvailableCount = 8 noExpiry := credit("forever", time.Hour) noExpiry.ExpiresAt = nil + noExpiry.ExpiryKnown = true redeemed := credit("redeemed", time.Minute) redeemed.Status = "redeemed" unknown := credit("unknown", time.Minute) @@ -148,6 +188,7 @@ func TestResetSafetyMessages(t *testing.T) { } if mode == "non-expiring" { m.snapshot.RateLimitResetCredits.Credits[0].ExpiresAt = nil + m.snapshot.RateLimitResetCredits.Credits[0].ExpiryKnown = true } if mode == "none" { m.snapshot.RateLimitResetCredits.AvailableCount = 0 diff --git a/internal/ui/reset_warning_test.go b/internal/ui/reset_warning_test.go index de03aef..40bb473 100644 --- a/internal/ui/reset_warning_test.go +++ b/internal/ui/reset_warning_test.go @@ -9,6 +9,7 @@ import ( "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" "github.com/merefield/codexometer/internal/codex" + "github.com/merefield/codexometer/internal/i18n" ) func TestResetWarningClickSurfaces(t *testing.T) { @@ -76,6 +77,28 @@ func TestResetWarningClickSurfaces(t *testing.T) { } } +func TestResetWarningsUseLocale(t *testing.T) { + m, _ := resetModel() + want := i18n.Text("Expiry order unavailable; backend chooses the credit.") + body := strings.Join(m.resetDetailLines(1000, paletteFor(m.theme)), "\n") + if !strings.Contains(body, want) { + t.Fatal("inventory fallback is not localised") + } + u, _ := m.pressQuotaReset() + if !strings.Contains(u.(Model).resetNotice, want) { + t.Fatal("confirmation fallback is not localised") + } + m.snapshot.RateLimitResetCredits.Credits = []codex.ResetCredit{credit("first", time.Hour)} + body = strings.Join(m.resetDetailLines(1000, paletteFor(m.theme)), "\n") + if !strings.Contains(body, i18n.Format("EXPIRING SOON // within %d hours", m.resetWarningHours)) { + t.Fatal("expiry status is not localised") + } + unknown := codex.ResetCredit{} + if resetCreditExpiry(unknown) != i18n.Text("Expiry information unavailable.") { + t.Fatal("unknown expiry is not localised") + } +} + func TestResetWarningVisibility(t *testing.T) { m, _ := resetModel() for _, age := range []time.Duration{-time.Hour, 73 * time.Hour} {