diff --git a/README.md b/README.md index 1fbe8f6..b4e7147 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 (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 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,53 @@ 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 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. + +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 +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 +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”; 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 @@ -1883,6 +1932,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 ``` @@ -1903,12 +1953,15 @@ 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 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 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 +1973,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..6cd9b96 --- /dev/null +++ b/internal/codex/reset_credits_test.go @@ -0,0 +1,68 @@ +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") + } +} + +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 6dd4cbb..69b76a6 100644 --- a/internal/codex/types.go +++ b/internal/codex/types.go @@ -1,6 +1,7 @@ package codex import ( + "encoding/json" "fmt" "sort" "strings" @@ -47,7 +48,59 @@ 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"` + 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 { diff --git a/internal/i18n/locales/da.json b/internal/i18n/locales/da.json index 35c0e2d..a901d45 100644 --- a/internal/i18n/locales/da.json +++ b/internal/i18n/locales/da.json @@ -1,4 +1,14 @@ { + "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.", + "⚠ RESET EXPIRES IN %s": "⚠ NULSTILLING UDLØBER OM %s", + "⚠ EXPIRES %s": "⚠ UDLØBER %s", + "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..6c3f5cc 100644 --- a/internal/i18n/locales/de.json +++ b/internal/i18n/locales/de.json @@ -1,4 +1,14 @@ { + "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.", + "⚠ 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 →", "APPROVAL →": "FREIGABE →", "←/→ DETAIL": "←/→ DETAILS", diff --git a/internal/i18n/locales/en-GB.json b/internal/i18n/locales/en-GB.json index 3e324c2..2c8497c 100644 --- a/internal/i18n/locales/en-GB.json +++ b/internal/i18n/locales/en-GB.json @@ -1,4 +1,14 @@ { + "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.", + "⚠ RESET EXPIRES IN %s": "⚠ RESET EXPIRES IN %s", + "⚠ EXPIRES %s": "⚠ EXPIRES %s", + "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..0e73b01 100644 --- a/internal/i18n/locales/es.json +++ b/internal/i18n/locales/es.json @@ -1,4 +1,14 @@ { + "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.", + "⚠ RESET EXPIRES IN %s": "⚠ REINICIO CADUCA EN %s", + "⚠ EXPIRES %s": "⚠ CADUCA %s", + "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..609e511 100644 --- a/internal/i18n/locales/et.json +++ b/internal/i18n/locales/et.json @@ -1,4 +1,14 @@ { + "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.", + "⚠ 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 →", "APPROVAL →": "KINNITAMINE →", "←/→ DETAIL": "←/→ ÜKSIKASJAD", diff --git a/internal/i18n/locales/fi.json b/internal/i18n/locales/fi.json index c0b0ee4..72d3a25 100644 --- a/internal/i18n/locales/fi.json +++ b/internal/i18n/locales/fi.json @@ -1,4 +1,14 @@ { + "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.", + "⚠ RESET EXPIRES IN %s": "⚠ NOLLAUS VANHENEE %s KULUTTUA", + "⚠ EXPIRES %s": "⚠ VANHENEE %s", + "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..7e10858 100644 --- a/internal/i18n/locales/fr.json +++ b/internal/i18n/locales/fr.json @@ -1,4 +1,14 @@ { + "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.", + "⚠ 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 →", "APPROVAL →": "APPROBATION →", "←/→ DETAIL": "←/→ DÉTAILS", diff --git a/internal/i18n/locales/it.json b/internal/i18n/locales/it.json index bfc3f25..8c35e5b 100644 --- a/internal/i18n/locales/it.json +++ b/internal/i18n/locales/it.json @@ -1,4 +1,14 @@ { + "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.", + "⚠ RESET EXPIRES IN %s": "⚠ RIPRISTINO SCADE TRA %s", + "⚠ EXPIRES %s": "⚠ SCADE %s", + "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..23f3087 100644 --- a/internal/i18n/locales/ja.json +++ b/internal/i18n/locales/ja.json @@ -1,4 +1,14 @@ { + "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.": "有効期限情報が不完全です。他のクレジットが先に失効する可能性があります。", + "⚠ RESET EXPIRES IN %s": "⚠ リセット期限まで %s", + "⚠ EXPIRES %s": "⚠ 期限 %s", + "RESETS": "リセット", + "╭ RESETS ╮": "╭ リセット ╮", "APPROVAL — OPEN DETAIL →": "承認 — 詳細を開く →", "APPROVAL →": "承認 →", "←/→ DETAIL": "←/→ 詳細", diff --git a/internal/i18n/locales/nb.json b/internal/i18n/locales/nb.json index d4cc2e7..6d649c2 100644 --- a/internal/i18n/locales/nb.json +++ b/internal/i18n/locales/nb.json @@ -1,4 +1,14 @@ { + "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.", + "⚠ RESET EXPIRES IN %s": "⚠ NULLSTILLING UTLØPER OM %s", + "⚠ EXPIRES %s": "⚠ UTLØPER %s", + "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..e12fa15 100644 --- a/internal/i18n/locales/nl.json +++ b/internal/i18n/locales/nl.json @@ -1,4 +1,14 @@ { + "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.", + "⚠ RESET EXPIRES IN %s": "⚠ RESET VERLOOPT OVER %s", + "⚠ EXPIRES %s": "⚠ VERLOOPT %s", + "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..6672fbd 100644 --- a/internal/i18n/locales/pt-BR.json +++ b/internal/i18n/locales/pt-BR.json @@ -1,4 +1,14 @@ { + "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.", + "⚠ 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 →", "APPROVAL →": "APROVAÇÃO →", "←/→ DETAIL": "←/→ DETALHES", diff --git a/internal/i18n/locales/pt-PT.json b/internal/i18n/locales/pt-PT.json index e257cc6..f0de615 100644 --- a/internal/i18n/locales/pt-PT.json +++ b/internal/i18n/locales/pt-PT.json @@ -1,4 +1,14 @@ { + "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.", + "⚠ 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 →", "APPROVAL →": "APROVAÇÃO →", "←/→ DETAIL": "←/→ DETALHES", diff --git a/internal/i18n/locales/ru.json b/internal/i18n/locales/ru.json index fad4cad..68c561d 100644 --- a/internal/i18n/locales/ru.json +++ b/internal/i18n/locales/ru.json @@ -1,4 +1,14 @@ { + "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.": "Сведения о сроках неполные. Другие кредиты могут истечь раньше.", + "⚠ RESET EXPIRES IN %s": "⚠ СБРОС ИСТЕКАЕТ ЧЕРЕЗ %s", + "⚠ EXPIRES %s": "⚠ ИСТЕКАЕТ %s", + "RESETS": "СБРОСЫ", + "╭ RESETS ╮": "╭ СБРОСЫ ╮", "APPROVAL — OPEN DETAIL →": "ОДОБРЕНИЕ — ОТКРЫТЬ ПОДРОБНОСТИ →", "APPROVAL →": "ОДОБРЕНИЕ →", "←/→ DETAIL": "←/→ ПОДРОБНОСТИ", diff --git a/internal/i18n/locales/sv.json b/internal/i18n/locales/sv.json index 6446be9..2403216 100644 --- a/internal/i18n/locales/sv.json +++ b/internal/i18n/locales/sv.json @@ -1,4 +1,14 @@ { + "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.", + "⚠ 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 →", "APPROVAL →": "GODKÄNNANDE →", "←/→ DETAIL": "←/→ DETALJER", diff --git a/internal/i18n/locales/tr.json b/internal/i18n/locales/tr.json index 70daa7c..d87a901 100644 --- a/internal/i18n/locales/tr.json +++ b/internal/i18n/locales/tr.json @@ -1,4 +1,14 @@ { + "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.", + "⚠ RESET EXPIRES IN %s": "⚠ SIFIRLAMA %s İÇİNDE SONA ERER", + "⚠ EXPIRES %s": "⚠ SONA ERER %s", + "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..f5e2dea 100644 --- a/internal/i18n/locales/zh-Hans.json +++ b/internal/i18n/locales/zh-Hans.json @@ -1,4 +1,14 @@ { + "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.": "有效期信息不完整。其他重置额度可能更早过期。", + "⚠ RESET EXPIRES IN %s": "⚠ 重置将在 %s 后过期", + "⚠ EXPIRES %s": "⚠ 过期 %s", + "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..1a1b19b 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 Resets expiry-data safety notice; // all themes, views and three terminal sizes are covered. - const want = "3813648a9bb9fcb16559f4eea5f1375f0e6fdc611d0c6d1191545966b7c0474d" + 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 c145f3e..959f699 100644 --- a/internal/ui/localisation_test.go +++ b/internal/ui/localisation_test.go @@ -55,6 +55,8 @@ 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("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/model.go b/internal/ui/model.go index 1b1d25a..a46b5ce 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -62,9 +62,13 @@ type Model struct { versionHovered bool history accountHistoryState resetThreshold int + resetWarningHours int resetHovered bool + resetWarningHovered bool resetBusy bool resetKey, resetAccount, resetNotice string + resetCreditID string + resetScroll int resetConfirmUntil time.Time resetRevision uint64 fetcher Fetcher @@ -397,6 +401,7 @@ func New(fetcher Fetcher, refreshEvery time.Duration) Model { } model := Model{ resetThreshold: 80, + resetWarningHours: 72, fetcher: fetcher, refreshEvery: refreshEvery, monitorAutoStart: true, @@ -475,6 +480,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 +503,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) @@ -724,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() } @@ -1357,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 @@ -1368,7 +1396,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 { @@ -1383,14 +1411,12 @@ 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) 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..b4f502d 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,6 +20,65 @@ type resetConsumer interface { ConsumeReset(context.Context, string, string) (string, error) } +type resetCreditConsumer interface { + ConsumeResetCredit(context.Context, string, string, string) (string, error) +} + +// 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 { + 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.HasKnownExpiry() != b.HasKnownExpiry() { + if a.HasKnownExpiry() { + return -1 + } + return 1 + } + 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 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. // Zero bypasses consumption checks, but still requires an available credit. func (m *Model) SetResetThreshold(percent int) { m.resetThreshold = min(max(percent, 0), 100) } @@ -47,6 +109,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 +135,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 { @@ -80,28 +150,98 @@ func (m Model) resetLabel() string { return i18n.Format("[ RESET // %d ]", m.snapshot.RateLimitResetCredits.AvailableCount) } -// Reserve the same horizontal span for rendering and hit testing. +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) + } + } + 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 +} + 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 } - return label != "" && !(m.loading && len(m.snapshot.Meters()) == 0) && y == g.tabsY && x >= 2+w+1 && x < 2+g.contentWidth + 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) + } + 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) { @@ -117,9 +257,32 @@ 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 && credits[0].HasKnownExpiry() { + 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.") + 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 += " " + i18n.Text("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 +291,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 && credit.HasKnownExpiry() { + 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 +324,103 @@ 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.HasKnownExpiry() { + return i18n.Text("Expiry information unavailable.") + } + 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) resetExpiryDataNotice() string { + summary := m.snapshot.RateLimitResetCredits + if summary != nil && summary.AvailableCount <= 0 { + return "" + } + 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 known < 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.", 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(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, 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 && credit.HasKnownExpiry() { + 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..5952a27 --- /dev/null +++ b/internal/ui/reset_credits_test.go @@ -0,0 +1,254 @@ +package ui + +import ( + "context" + "encoding/json" + "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 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) +} + +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 + noExpiry.ExpiryKnown = true + 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() || 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)} + 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 information 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 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 + m.snapshot.RateLimitResetCredits.Credits[0].ExpiryKnown = true + } + 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() + 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 + 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) { + 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/reset_warning_test.go b/internal/ui/reset_warning_test.go new file mode 100644 index 0000000..40bb473 --- /dev/null +++ b/internal/ui/reset_warning_test.go @@ -0,0 +1,151 @@ +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" + "github.com/merefield/codexometer/internal/i18n" +) + +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 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} { + 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 a69e748..8f0d8dd 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)) @@ -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/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..e085f56 100644 --- a/intro-post.md +++ b/intro-post.md @@ -57,10 +57,14 @@ 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. 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. + +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`): diff --git a/main.go b/main.go index 03ef6fd..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, "quota consumption percentage required to show reset (0-100; 0 always shows available resets)") - 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")