Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 59 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
```

Expand All @@ -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
Expand All @@ -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.

Expand Down
13 changes: 11 additions & 2 deletions internal/codex/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
Expand Down
11 changes: 9 additions & 2 deletions internal/codex/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, &params) != nil || params.Key != "test-attempt" {
if json.Unmarshal(request.Params, &params) != 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
}
Expand Down Expand Up @@ -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")
}
Expand Down
68 changes: 68 additions & 0 deletions internal/codex/reset_credits_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
55 changes: 54 additions & 1 deletion internal/codex/types.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package codex

import (
"encoding/json"
"fmt"
"sort"
"strings"
Expand Down Expand Up @@ -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"`
Comment thread
merefield marked this conversation as resolved.
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 {
Expand Down
10 changes: 10 additions & 0 deletions internal/i18n/locales/da.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
10 changes: 10 additions & 0 deletions internal/i18n/locales/de.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
10 changes: 10 additions & 0 deletions internal/i18n/locales/en-GB.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
10 changes: 10 additions & 0 deletions internal/i18n/locales/es.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
10 changes: 10 additions & 0 deletions internal/i18n/locales/et.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading