diff --git a/README.md b/README.md
index d10663f..ff632c1 100644
--- a/README.md
+++ b/README.md
@@ -2082,6 +2082,18 @@ are rejected before changing settings. `standard` clears an explicit tier;
omitting speed leaves the session's current tier intact.
Codexometer ships no enabled profile and does not infer which model is cheaper.
+When one or more steps are configured, Codexometer adds a **Thresholds** view
+after **Resets** within **Quota**, in both the terminal and experimental web interface. It keeps
+the complete policy visible in trigger order, including model, reasoning level,
+speed and `ask`/`auto` behavior, and marks the active and next steps against the
+longest Codex quota window. The tab is omitted entirely on ordinary launches;
+saved Thresholds navigation falls back to Bars when no policy is
+configured. Approvals remain attached to their individual sessions in
+**Sessions** rather than being actioned from the policy overview.
+`ACTIVE` identifies the selected policy, not confirmation that every session has
+applied it; `PASSED` means a higher threshold now takes precedence. Scroll long
+policies with the mouse wheel, arrow keys or Page Up/Down in the terminal.
+
The optional final mode defaults to **`ask`**, preserving per-session approval
for existing command lines. **`auto`** authorizes applying the profile at launch:
when the threshold is reached, each eligible loaded session is updated for its
diff --git a/internal/codex/quota_profiles.go b/internal/codex/quota_profiles.go
index 65e41b9..649fcae 100644
--- a/internal/codex/quota_profiles.go
+++ b/internal/codex/quota_profiles.go
@@ -4,10 +4,64 @@ import (
"context"
"errors"
"fmt"
+ "slices"
"sync"
"time"
)
+type QuotaStepStage string
+
+const (
+ QuotaStepConfigured QuotaStepStage = "CONFIGURED"
+ QuotaStepPassed QuotaStepStage = "PASSED"
+ QuotaStepActive QuotaStepStage = "ACTIVE"
+ QuotaStepNext QuotaStepStage = "NEXT"
+ QuotaStepArmed QuotaStepStage = "ARMED"
+)
+
+type QuotaStepStatus struct {
+ Step QuotaStep
+ Stage QuotaStepStage
+ Remaining int
+}
+
+// QuotaStepStatuses returns a stable, trigger-ordered policy projection for
+// any presentation. The used percentage is nil until the policy window can be
+// identified authoritatively.
+func QuotaStepStatuses(s Snapshot, steps []QuotaStep) ([]QuotaStepStatus, *int) {
+ ordered := append([]QuotaStep(nil), steps...)
+ slices.SortStableFunc(ordered, func(a, b QuotaStep) int { return a.Threshold - b.Threshold })
+ used, active := -1, -1
+ if meter, _, ok := QuotaPolicyWindow(s); ok {
+ used = meter.Window.UsedPercent
+ for index, step := range ordered {
+ if step.Threshold <= used {
+ active = index
+ }
+ }
+ }
+ statuses := make([]QuotaStepStatus, 0, len(ordered))
+ for index, step := range ordered {
+ status := QuotaStepStatus{Step: step, Stage: QuotaStepArmed}
+ switch {
+ case used < 0:
+ status.Stage = QuotaStepConfigured
+ case index < active:
+ status.Stage = QuotaStepPassed
+ case index == active:
+ status.Stage = QuotaStepActive
+ case index == active+1:
+ status.Stage = QuotaStepNext
+ status.Remaining = max(step.Threshold-used, 0)
+ }
+ statuses = append(statuses, status)
+ }
+ if used < 0 {
+ return statuses, nil
+ }
+ return statuses, &used
+}
+
// QuotaProfiles owns the policy lifecycle independently of either presentation.
// Its lock also serializes inventory and writes from browser tabs or UI commands.
type QuotaProfiles struct {
diff --git a/internal/codex/quota_profiles_test.go b/internal/codex/quota_profiles_test.go
index f441eca..f42cee1 100644
--- a/internal/codex/quota_profiles_test.go
+++ b/internal/codex/quota_profiles_test.go
@@ -74,6 +74,30 @@ func TestQuotaProfileCaptureAndSelection(t *testing.T) {
}
}
+func TestQuotaStepStatusesAreSortedAndClassified(t *testing.T) {
+ s := DemoSnapshot()
+ s.AccountFingerprint = "account"
+ s.RateLimits.Secondary.UsedPercent = 65
+ statuses, used := QuotaStepStatuses(s, []QuotaStep{{Threshold: 90}, {Threshold: 25}, {Threshold: 80}, {Threshold: 50}})
+ want := []QuotaStepStage{QuotaStepPassed, QuotaStepActive, QuotaStepNext, QuotaStepArmed}
+ if used == nil || *used != 65 || len(statuses) != len(want) {
+ t.Fatalf("status summary = %#v, used=%v", statuses, used)
+ }
+ for index, stage := range want {
+ if statuses[index].Stage != stage {
+ t.Fatalf("status %d = %#v, want %s", index, statuses[index], stage)
+ }
+ }
+ if statuses[2].Remaining != 15 {
+ t.Fatalf("next remaining = %d", statuses[2].Remaining)
+ }
+
+ configured, used := QuotaStepStatuses(Snapshot{}, []QuotaStep{{Threshold: 80}})
+ if used != nil || configured[0].Stage != QuotaStepConfigured {
+ t.Fatalf("unobserved status = %#v, used=%v", configured, used)
+ }
+}
+
func TestQuotaProfilesStaleAttemptConsumed(t *testing.T) {
c := &policyTestClient{}
p := NewQuotaProfiles(c)
diff --git a/internal/i18n/locales/da.json b/internal/i18n/locales/da.json
index 47e2a2b..87f0eba 100644
--- a/internal/i18n/locales/da.json
+++ b/internal/i18n/locales/da.json
@@ -1,4 +1,17 @@
{
+ "THRESHOLDS": "TÆRSKLER",
+ "MODEL STEP POLICY": "POLITIK FOR MODELTRIN",
+ "QUOTA WINDOW UNAVAILABLE": "KVOTEVINDUE IKKE TILGÆNGELIGT",
+ "QUOTA WINDOW // %d%% USED": "KVOTEVINDUE // %d%% BRUGT",
+ "TRIGGER": "UDLØSER",
+ "ACTION": "HANDLING",
+ "STATE": "STATUS",
+ "ASK": "SPØRG",
+ "AUTO": "AUTO",
+ "ARMED": "KLAR",
+ "CONFIGURED": "KONFIGURERET",
+ "PASSED": "PASSERET",
+ "NEXT // %d PP": "NÆSTE // %d PP",
"WHY THIS CHANGE": "HVORFOR DENNE ÆNDRING",
"CURRENT PROFILE": "AKTUEL PROFIL",
"PROPOSED PROFILE": "FORESLÅET PROFIL",
diff --git a/internal/i18n/locales/de.json b/internal/i18n/locales/de.json
index 5a33086..7fc38d4 100644
--- a/internal/i18n/locales/de.json
+++ b/internal/i18n/locales/de.json
@@ -1,4 +1,17 @@
{
+ "THRESHOLDS": "SCHWELLENWERTE",
+ "MODEL STEP POLICY": "MODELLSTUFENREGEL",
+ "QUOTA WINDOW UNAVAILABLE": "KONTINGENTFENSTER NICHT VERFÜGBAR",
+ "QUOTA WINDOW // %d%% USED": "KONTINGENTFENSTER // %d%% VERBRAUCHT",
+ "TRIGGER": "AUSLÖSER",
+ "ACTION": "AKTION",
+ "STATE": "STATUS",
+ "ASK": "FRAGEN",
+ "AUTO": "AUTO",
+ "ARMED": "BEREIT",
+ "CONFIGURED": "KONFIGURIERT",
+ "PASSED": "ÜBERSCHRITTEN",
+ "NEXT // %d PP": "NÄCHSTE // %d PP",
"WHY THIS CHANGE": "WARUM DIESE ÄNDERUNG",
"CURRENT PROFILE": "AKTUELLES PROFIL",
"PROPOSED PROFILE": "VORGESCHLAGENES PROFIL",
diff --git a/internal/i18n/locales/en-GB.json b/internal/i18n/locales/en-GB.json
index 6f5be87..f5d3cd7 100644
--- a/internal/i18n/locales/en-GB.json
+++ b/internal/i18n/locales/en-GB.json
@@ -1,4 +1,17 @@
{
+ "THRESHOLDS": "THRESHOLDS",
+ "MODEL STEP POLICY": "MODEL STEP POLICY",
+ "QUOTA WINDOW UNAVAILABLE": "QUOTA WINDOW UNAVAILABLE",
+ "QUOTA WINDOW // %d%% USED": "QUOTA WINDOW // %d%% USED",
+ "TRIGGER": "TRIGGER",
+ "ACTION": "ACTION",
+ "STATE": "STATE",
+ "ASK": "ASK",
+ "AUTO": "AUTO",
+ "ARMED": "ARMED",
+ "CONFIGURED": "CONFIGURED",
+ "PASSED": "PASSED",
+ "NEXT // %d PP": "NEXT // %d PP",
"WHY THIS CHANGE": "WHY THIS CHANGE",
"CURRENT PROFILE": "CURRENT PROFILE",
"PROPOSED PROFILE": "PROPOSED PROFILE",
diff --git a/internal/i18n/locales/es.json b/internal/i18n/locales/es.json
index 8bf9670..a6422b7 100644
--- a/internal/i18n/locales/es.json
+++ b/internal/i18n/locales/es.json
@@ -1,4 +1,17 @@
{
+ "THRESHOLDS": "UMBRALES",
+ "MODEL STEP POLICY": "POLÍTICA DE CAMBIO DE MODELO",
+ "QUOTA WINDOW UNAVAILABLE": "VENTANA DE CUOTA NO DISPONIBLE",
+ "QUOTA WINDOW // %d%% USED": "VENTANA DE CUOTA // %d%% USADO",
+ "TRIGGER": "ACTIVACIÓN",
+ "ACTION": "ACCIÓN",
+ "STATE": "ESTADO",
+ "ASK": "PREGUNTAR",
+ "AUTO": "AUTO",
+ "ARMED": "PREPARADO",
+ "CONFIGURED": "CONFIGURADO",
+ "PASSED": "SUPERADO",
+ "NEXT // %d PP": "SIGUIENTE // %d PP",
"WHY THIS CHANGE": "MOTIVO DEL CAMBIO",
"CURRENT PROFILE": "PERFIL ACTUAL",
"PROPOSED PROFILE": "PERFIL PROPUESTO",
diff --git a/internal/i18n/locales/et.json b/internal/i18n/locales/et.json
index f7c8357..2ec653a 100644
--- a/internal/i18n/locales/et.json
+++ b/internal/i18n/locales/et.json
@@ -1,4 +1,17 @@
{
+ "THRESHOLDS": "LÄVENDID",
+ "MODEL STEP POLICY": "MUDELIASTMETE REEGEL",
+ "QUOTA WINDOW UNAVAILABLE": "KVOODIAKEN POLE SAADAVAL",
+ "QUOTA WINDOW // %d%% USED": "KVOODIAKEN // %d%% KASUTATUD",
+ "TRIGGER": "KÄIVITI",
+ "ACTION": "TOIMING",
+ "STATE": "OLEK",
+ "ASK": "KÜSI",
+ "AUTO": "AUTO",
+ "ARMED": "VALMIS",
+ "CONFIGURED": "SEADISTATUD",
+ "PASSED": "ÜLETATUD",
+ "NEXT // %d PP": "JÄRGMINE // %d PP",
"WHY THIS CHANGE": "MUUDATUSE PÕHJUS",
"CURRENT PROFILE": "PRAEGUNE PROFIIL",
"PROPOSED PROFILE": "SOOVITATUD PROFIIL",
diff --git a/internal/i18n/locales/fi.json b/internal/i18n/locales/fi.json
index a041a12..2d144e8 100644
--- a/internal/i18n/locales/fi.json
+++ b/internal/i18n/locales/fi.json
@@ -1,4 +1,17 @@
{
+ "THRESHOLDS": "KYNNYSARVOT",
+ "MODEL STEP POLICY": "MALLIPORRAIDEN KÄYTÄNTÖ",
+ "QUOTA WINDOW UNAVAILABLE": "KIINTIÖIKKUNA EI SAATAVILLA",
+ "QUOTA WINDOW // %d%% USED": "KIINTIÖIKKUNA // %d%% KÄYTETTY",
+ "TRIGGER": "LAUKAISIN",
+ "ACTION": "TOIMINTO",
+ "STATE": "TILA",
+ "ASK": "KYSY",
+ "AUTO": "AUTO",
+ "ARMED": "VALMIINA",
+ "CONFIGURED": "MÄÄRITETTY",
+ "PASSED": "OHITETTU",
+ "NEXT // %d PP": "SEURAAVA // %d PP",
"WHY THIS CHANGE": "MUUTOKSEN SYY",
"CURRENT PROFILE": "NYKYINEN PROFIILI",
"PROPOSED PROFILE": "EHDOTETTU PROFIILI",
diff --git a/internal/i18n/locales/fr.json b/internal/i18n/locales/fr.json
index da9e0f9..9cd4328 100644
--- a/internal/i18n/locales/fr.json
+++ b/internal/i18n/locales/fr.json
@@ -1,4 +1,17 @@
{
+ "THRESHOLDS": "SEUILS",
+ "MODEL STEP POLICY": "POLITIQUE DE PALIERS DE MODÈLE",
+ "QUOTA WINDOW UNAVAILABLE": "FENÊTRE DE QUOTA INDISPONIBLE",
+ "QUOTA WINDOW // %d%% USED": "FENÊTRE DE QUOTA // %d%% UTILISÉ",
+ "TRIGGER": "DÉCLENCHEUR",
+ "ACTION": "ACTION",
+ "STATE": "ÉTAT",
+ "ASK": "DEMANDER",
+ "AUTO": "AUTO",
+ "ARMED": "PRÊT",
+ "CONFIGURED": "CONFIGURÉ",
+ "PASSED": "DÉPASSÉ",
+ "NEXT // %d PP": "SUIVANT // %d PP",
"WHY THIS CHANGE": "POURQUOI CE CHANGEMENT",
"CURRENT PROFILE": "PROFIL ACTUEL",
"PROPOSED PROFILE": "PROFIL PROPOSÉ",
diff --git a/internal/i18n/locales/it.json b/internal/i18n/locales/it.json
index 6b67433..288cd2f 100644
--- a/internal/i18n/locales/it.json
+++ b/internal/i18n/locales/it.json
@@ -1,4 +1,17 @@
{
+ "THRESHOLDS": "SOGLIE",
+ "MODEL STEP POLICY": "CRITERI DI CAMBIO MODELLO",
+ "QUOTA WINDOW UNAVAILABLE": "FINESTRA QUOTA NON DISPONIBILE",
+ "QUOTA WINDOW // %d%% USED": "FINESTRA QUOTA // %d%% USATO",
+ "TRIGGER": "ATTIVAZIONE",
+ "ACTION": "AZIONE",
+ "STATE": "STATO",
+ "ASK": "CHIEDI",
+ "AUTO": "AUTO",
+ "ARMED": "PRONTO",
+ "CONFIGURED": "CONFIGURATO",
+ "PASSED": "SUPERATO",
+ "NEXT // %d PP": "PROSSIMO // %d PP",
"WHY THIS CHANGE": "PERCHÉ QUESTO CAMBIAMENTO",
"CURRENT PROFILE": "PROFILO ATTUALE",
"PROPOSED PROFILE": "PROFILO PROPOSTO",
diff --git a/internal/i18n/locales/ja.json b/internal/i18n/locales/ja.json
index 8bb811f..649d0b2 100644
--- a/internal/i18n/locales/ja.json
+++ b/internal/i18n/locales/ja.json
@@ -1,4 +1,17 @@
{
+ "THRESHOLDS": "しきい値",
+ "MODEL STEP POLICY": "モデル切替ポリシー",
+ "QUOTA WINDOW UNAVAILABLE": "クォータ期間を取得できません",
+ "QUOTA WINDOW // %d%% USED": "クォータ期間 // %d%% 使用",
+ "TRIGGER": "トリガー",
+ "ACTION": "動作",
+ "STATE": "状態",
+ "ASK": "確認",
+ "AUTO": "自動",
+ "ARMED": "待機",
+ "CONFIGURED": "設定済み",
+ "PASSED": "通過済み",
+ "NEXT // %d PP": "次 // %d PP",
"WHY THIS CHANGE": "変更の理由",
"CURRENT PROFILE": "現在のプロファイル",
"PROPOSED PROFILE": "提案するプロファイル",
diff --git a/internal/i18n/locales/nb.json b/internal/i18n/locales/nb.json
index 2f2330b..6801c36 100644
--- a/internal/i18n/locales/nb.json
+++ b/internal/i18n/locales/nb.json
@@ -1,4 +1,17 @@
{
+ "THRESHOLDS": "TERSKLER",
+ "MODEL STEP POLICY": "REGLER FOR MODELLTRINN",
+ "QUOTA WINDOW UNAVAILABLE": "KVOTEVINDU IKKE TILGJENGELIG",
+ "QUOTA WINDOW // %d%% USED": "KVOTEVINDU // %d%% BRUKT",
+ "TRIGGER": "UTLØSER",
+ "ACTION": "HANDLING",
+ "STATE": "STATUS",
+ "ASK": "SPØR",
+ "AUTO": "AUTO",
+ "ARMED": "KLAR",
+ "CONFIGURED": "KONFIGURERT",
+ "PASSED": "PASSERT",
+ "NEXT // %d PP": "NESTE // %d PP",
"WHY THIS CHANGE": "HVORFOR DENNE ENDRINGEN",
"CURRENT PROFILE": "GJELDENDE PROFIL",
"PROPOSED PROFILE": "FORESLÅTT PROFIL",
diff --git a/internal/i18n/locales/nl.json b/internal/i18n/locales/nl.json
index b989ddb..20eae91 100644
--- a/internal/i18n/locales/nl.json
+++ b/internal/i18n/locales/nl.json
@@ -1,4 +1,17 @@
{
+ "THRESHOLDS": "DREMPELS",
+ "MODEL STEP POLICY": "BELEID VOOR MODELSTAPPEN",
+ "QUOTA WINDOW UNAVAILABLE": "QUOTAVENSTER NIET BESCHIKBAAR",
+ "QUOTA WINDOW // %d%% USED": "QUOTAVENSTER // %d%% GEBRUIKT",
+ "TRIGGER": "DREMPEL",
+ "ACTION": "ACTIE",
+ "STATE": "STATUS",
+ "ASK": "VRAGEN",
+ "AUTO": "AUTO",
+ "ARMED": "GEREED",
+ "CONFIGURED": "INGESTELD",
+ "PASSED": "GEPASSEERD",
+ "NEXT // %d PP": "VOLGENDE // %d PP",
"WHY THIS CHANGE": "WAAROM DEZE WIJZIGING",
"CURRENT PROFILE": "HUIDIG PROFIEL",
"PROPOSED PROFILE": "VOORGESTELD PROFIEL",
diff --git a/internal/i18n/locales/pt-BR.json b/internal/i18n/locales/pt-BR.json
index 0fd5a06..727177f 100644
--- a/internal/i18n/locales/pt-BR.json
+++ b/internal/i18n/locales/pt-BR.json
@@ -1,4 +1,17 @@
{
+ "THRESHOLDS": "LIMITES",
+ "MODEL STEP POLICY": "POLÍTICA DE ETAPAS DO MODELO",
+ "QUOTA WINDOW UNAVAILABLE": "JANELA DE COTA INDISPONÍVEL",
+ "QUOTA WINDOW // %d%% USED": "JANELA DE COTA // %d%% USADA",
+ "TRIGGER": "ACIONAMENTO",
+ "ACTION": "AÇÃO",
+ "STATE": "ESTADO",
+ "ASK": "PERGUNTAR",
+ "AUTO": "AUTO",
+ "ARMED": "PRONTO",
+ "CONFIGURED": "CONFIGURADO",
+ "PASSED": "ULTRAPASSADO",
+ "NEXT // %d PP": "PRÓXIMO // %d PP",
"WHY THIS CHANGE": "MOTIVO DA ALTERAÇÃO",
"CURRENT PROFILE": "PERFIL ATUAL",
"PROPOSED PROFILE": "PERFIL PROPOSTO",
diff --git a/internal/i18n/locales/pt-PT.json b/internal/i18n/locales/pt-PT.json
index 9c4bcf9..a7db9c0 100644
--- a/internal/i18n/locales/pt-PT.json
+++ b/internal/i18n/locales/pt-PT.json
@@ -1,4 +1,17 @@
{
+ "THRESHOLDS": "LIMITES",
+ "MODEL STEP POLICY": "POLÍTICA DE ETAPAS DO MODELO",
+ "QUOTA WINDOW UNAVAILABLE": "JANELA DE QUOTA INDISPONÍVEL",
+ "QUOTA WINDOW // %d%% USED": "JANELA DE QUOTA // %d%% UTILIZADA",
+ "TRIGGER": "ACIONAMENTO",
+ "ACTION": "AÇÃO",
+ "STATE": "ESTADO",
+ "ASK": "PERGUNTAR",
+ "AUTO": "AUTO",
+ "ARMED": "PRONTO",
+ "CONFIGURED": "CONFIGURADO",
+ "PASSED": "ULTRAPASSADO",
+ "NEXT // %d PP": "PRÓXIMO // %d PP",
"WHY THIS CHANGE": "MOTIVO DA ALTERAÇÃO",
"CURRENT PROFILE": "PERFIL ATUAL",
"PROPOSED PROFILE": "PERFIL PROPOSTO",
diff --git a/internal/i18n/locales/ru.json b/internal/i18n/locales/ru.json
index c6b196f..e346b82 100644
--- a/internal/i18n/locales/ru.json
+++ b/internal/i18n/locales/ru.json
@@ -1,4 +1,17 @@
{
+ "THRESHOLDS": "ПОРОГИ",
+ "MODEL STEP POLICY": "ПОЛИТИКА СМЕНЫ МОДЕЛИ",
+ "QUOTA WINDOW UNAVAILABLE": "ОКНО КВОТЫ НЕДОСТУПНО",
+ "QUOTA WINDOW // %d%% USED": "ОКНО КВОТЫ // %d%% ИСПОЛЬЗОВАНО",
+ "TRIGGER": "ПОРОГ",
+ "ACTION": "ДЕЙСТВИЕ",
+ "STATE": "СОСТОЯНИЕ",
+ "ASK": "СПРОСИТЬ",
+ "AUTO": "АВТО",
+ "ARMED": "ГОТОВО",
+ "CONFIGURED": "НАСТРОЕНО",
+ "PASSED": "ПРОЙДЕНО",
+ "NEXT // %d PP": "СЛЕДУЮЩИЙ // %d ПП",
"WHY THIS CHANGE": "ПРИЧИНА ИЗМЕНЕНИЯ",
"CURRENT PROFILE": "ТЕКУЩИЙ ПРОФИЛЬ",
"PROPOSED PROFILE": "ПРЕДЛАГАЕМЫЙ ПРОФИЛЬ",
diff --git a/internal/i18n/locales/sv.json b/internal/i18n/locales/sv.json
index 6f1c498..c453b99 100644
--- a/internal/i18n/locales/sv.json
+++ b/internal/i18n/locales/sv.json
@@ -1,4 +1,17 @@
{
+ "THRESHOLDS": "TRÖSKLAR",
+ "MODEL STEP POLICY": "POLICY FÖR MODELLSTEG",
+ "QUOTA WINDOW UNAVAILABLE": "KVOTFÖNSTER INTE TILLGÄNGLIGT",
+ "QUOTA WINDOW // %d%% USED": "KVOTFÖNSTER // %d%% ANVÄNT",
+ "TRIGGER": "UTLÖSARE",
+ "ACTION": "ÅTGÄRD",
+ "STATE": "STATUS",
+ "ASK": "FRÅGA",
+ "AUTO": "AUTO",
+ "ARMED": "REDO",
+ "CONFIGURED": "KONFIGURERAD",
+ "PASSED": "PASSERAD",
+ "NEXT // %d PP": "NÄSTA // %d PP",
"WHY THIS CHANGE": "VARFÖR DENNA ÄNDRING",
"CURRENT PROFILE": "AKTUELL PROFIL",
"PROPOSED PROFILE": "FÖRESLAGEN PROFIL",
diff --git a/internal/i18n/locales/tr.json b/internal/i18n/locales/tr.json
index 900b107..79ddfe4 100644
--- a/internal/i18n/locales/tr.json
+++ b/internal/i18n/locales/tr.json
@@ -1,4 +1,17 @@
{
+ "THRESHOLDS": "EŞİKLER",
+ "MODEL STEP POLICY": "MODEL ADIMI POLİTİKASI",
+ "QUOTA WINDOW UNAVAILABLE": "KOTA PENCERESİ KULLANILAMIYOR",
+ "QUOTA WINDOW // %d%% USED": "KOTA PENCERESİ // %d%% KULLANILDI",
+ "TRIGGER": "TETİKLEYİCİ",
+ "ACTION": "EYLEM",
+ "STATE": "DURUM",
+ "ASK": "SOR",
+ "AUTO": "OTOMATİK",
+ "ARMED": "HAZIR",
+ "CONFIGURED": "YAPILANDIRILDI",
+ "PASSED": "GEÇİLDİ",
+ "NEXT // %d PP": "SONRAKİ // %d PP",
"WHY THIS CHANGE": "BU DEĞİŞİKLİĞİN NEDENİ",
"CURRENT PROFILE": "GEÇERLİ PROFİL",
"PROPOSED PROFILE": "ÖNERİLEN PROFİL",
diff --git a/internal/i18n/locales/zh-Hans.json b/internal/i18n/locales/zh-Hans.json
index 13f866c..fbe0129 100644
--- a/internal/i18n/locales/zh-Hans.json
+++ b/internal/i18n/locales/zh-Hans.json
@@ -1,4 +1,17 @@
{
+ "THRESHOLDS": "阈值",
+ "MODEL STEP POLICY": "模型切换策略",
+ "QUOTA WINDOW UNAVAILABLE": "配额周期不可用",
+ "QUOTA WINDOW // %d%% USED": "配额周期 // 已使用 %d%%",
+ "TRIGGER": "触发值",
+ "ACTION": "操作",
+ "STATE": "状态",
+ "ASK": "询问",
+ "AUTO": "自动",
+ "ARMED": "待命",
+ "CONFIGURED": "已配置",
+ "PASSED": "已越过",
+ "NEXT // %d PP": "下一个 // %d 个百分点",
"WHY THIS CHANGE": "更改原因",
"CURRENT PROFILE": "当前配置",
"PROPOSED PROFILE": "建议配置",
diff --git a/internal/ui/english_snapshot_test.go b/internal/ui/english_snapshot_test.go
index 74c277a..59e3ad1 100644
--- a/internal/ui/english_snapshot_test.go
+++ b/internal/ui/english_snapshot_test.go
@@ -17,6 +17,11 @@ func TestEnglishPresentationSnapshot(t *testing.T) {
stripVersionLink := regexp.MustCompile(regexp.QuoteMeta(ansi.SetHyperlink(versionHighlightsURL(snapshotVersion))) + `(.*?)` + regexp.QuoteMeta(ansi.ResetHyperlink()))
for theme := themeHacker; theme < themeCount; theme++ {
for view := viewBars; view < viewCount; view++ {
+ // The opt-in Thresholds view has separate coverage. Preserve the
+ // existing snapshot for launches without a threshold policy.
+ if view == viewThresholds {
+ continue
+ }
for _, size := range []struct{ w, h int }{{40, 16}, {80, 24}, {120, 40}} {
snapshot := codex.DemoSnapshot()
snapshot.RateLimits.Primary.ResetsAt = nil
diff --git a/internal/ui/model.go b/internal/ui/model.go
index bc47d52..8fab125 100644
--- a/internal/ui/model.go
+++ b/internal/ui/model.go
@@ -73,6 +73,7 @@ type Model struct {
resetConfirmUntil time.Time
resetRevision uint64
quotaSteps []codex.QuotaStep
+ thresholdScroll int
quota quotaControl
quotaStepPending *codex.QuotaStep
quotaStepWindow string
@@ -603,6 +604,23 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
}
+ if m.meterView == viewThresholds {
+ 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 {
+ m.scrollThresholds(step)
+ return m, nil
+ }
+ }
if m.meterView == viewUsage {
if action, ok := historyKey(strings.ToLower(message.String())); ok {
m.activateHistory(action)
@@ -667,9 +685,9 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
return m.pressFooterButton(footerButtonView)
}
case "tab":
- return m.pressMainTab(m.currentMainTab().next())
+ return m.pressMainTab(m.adjacentMainTab(1))
case "shift+tab":
- return m.pressMainTab(m.currentMainTab().previous())
+ return m.pressMainTab(m.adjacentMainTab(-1))
case "r":
return m.pressFooterButton(footerButtonRefresh)
case "q":
@@ -862,6 +880,16 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.viewHovered = false
+ if m.meterView == viewThresholds {
+ switch mouse.Button {
+ case tea.MouseWheelUp:
+ m.scrollThresholds(-3)
+ return m, nil
+ case tea.MouseWheelDown:
+ m.scrollThresholds(3)
+ return m, nil
+ }
+ }
if m.meterView == viewBenchmark && m.benchmarkScopeOpen {
if item, ok := m.benchmarkScopeItemAt(mouse.X, mouse.Y); ok {
m.hoveredButton = footerButtonNone
@@ -1300,7 +1328,7 @@ func (m Model) activateFooterButton(button footerButtonID) (Model, tea.Cmd) {
m.persistPreferences()
case footerButtonView:
if m.meterView.isQuota() {
- m.meterView = m.meterView.nextQuota()
+ m.meterView = m.meterView.nextQuota(len(m.quotaSteps) > 0)
m.quotaMeterView = m.meterView
m.persistPreferences()
}
@@ -1493,7 +1521,7 @@ func (m Model) dashboardLayout() dashboardGeometry {
extraHeight += framedErrorHeight
}
meters := m.snapshot.Meters()
- if len(meters) == 0 && m.meterView != viewUsage && m.meterView != viewResets {
+ if len(meters) == 0 && m.meterView != viewUsage && m.meterView != viewResets && m.meterView != viewThresholds {
extraHeight += framedErrorHeight
}
if (m.meterView == viewBars || m.meterView == viewConsumptionPace || m.meterView == viewFuel) && len(meters) > 0 {
@@ -1513,7 +1541,7 @@ func (m Model) dashboardLayout() dashboardGeometry {
meterY := tabsY + tabsHeight + extraHeight
meterHeight := max(contentHeight-headerHeight-statusHeight-tabsHeight-extraHeight-footerHeight, 1)
footerY := meterY
- if m.meterView == viewUsage || m.meterView == viewResets || m.meterView == viewMonitor || m.meterView == viewBenchmark || len(m.snapshot.Meters()) > 0 {
+ if m.meterView == viewUsage || m.meterView == viewResets || m.meterView == viewThresholds || 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 6d86780..73a5ec0 100644
--- a/internal/ui/preferences.go
+++ b/internal/ui/preferences.go
@@ -81,7 +81,7 @@ func (m *Model) applyPreferences(preferences Preferences) {
if theme, ok := themePreferenceIDs[preferences.Theme]; ok {
m.theme = theme
}
- if view, ok := quotaViewPreferenceIDs[preferences.QuotaView]; ok {
+ if view, ok := quotaViewPreferenceIDs[preferences.QuotaView]; ok && (view != viewThresholds || len(m.quotaSteps) > 0) {
m.meterView = view
m.quotaMeterView = view
}
@@ -92,6 +92,10 @@ func (m *Model) applyPreferences(preferences Preferences) {
m.meterView = viewUsage
case "benchmark":
m.meterView = viewBenchmark
+ case "thresholds":
+ if len(m.quotaSteps) > 0 {
+ m.meterView = viewThresholds
+ }
}
if filter, ok := benchmarkFilterPreferenceIDs[preferences.BenchmarkFilter]; ok {
m.benchmarkFilter = filter
@@ -135,7 +139,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", viewResets: "resets",
+ viewBars: "bars", viewPie: "pie", viewConsumptionPace: "consumption-pace", viewFuel: "fuel-tank", viewResets: "resets", viewThresholds: "thresholds",
}
var quotaViewPreferenceIDs = reverseViewPreferences(quotaViewPreferenceNames)
diff --git a/internal/ui/preferences_test.go b/internal/ui/preferences_test.go
index 044d2cc..2287901 100644
--- a/internal/ui/preferences_test.go
+++ b/internal/ui/preferences_test.go
@@ -83,13 +83,14 @@ func TestPreferencesRememberMainTabSeparatelyFromQuotaView(t *testing.T) {
for tab, name := range mainTabPreferenceNames {
t.Run(name, func(t *testing.T) {
store := &memoryPreferenceStore{preferences: Preferences{QuotaView: "fuel-tank"}}
- m := NewWithPreferences(nil, time.Minute, store)
+ var fetcher Fetcher
+ m := NewWithPreferences(fetcher, time.Minute, store)
next, _ := m.pressMainTab(tab)
m = next.(Model)
if store.preferences.MainTab != name || store.preferences.QuotaView != "fuel-tank" {
t.Fatalf("saved preferences = %+v", store.preferences)
}
- restarted := NewWithPreferences(nil, time.Minute, store)
+ restarted := NewWithPreferences(fetcher, time.Minute, store)
if restarted.currentMainTab() != tab || restarted.selectedQuotaView() != viewFuel {
t.Fatal("restart lost tab or quota view")
}
@@ -102,6 +103,10 @@ func TestPreferencesRememberMainTabSeparatelyFromQuotaView(t *testing.T) {
}
})
}
+ m := NewWithPreferences(nil, time.Minute, &memoryPreferenceStore{preferences: Preferences{MainTab: "thresholds", QuotaView: "pie"}})
+ if m.meterView != viewPie {
+ t.Fatal("hidden Thresholds preference did not fall back to the saved quota view")
+ }
for _, tab := range []string{"", "unknown"} {
m := NewWithPreferences(nil, time.Minute, &memoryPreferenceStore{preferences: Preferences{MainTab: tab, QuotaView: "pie"}})
if m.meterView != viewPie {
@@ -110,6 +115,18 @@ func TestPreferencesRememberMainTabSeparatelyFromQuotaView(t *testing.T) {
}
}
+func TestThresholdQuotaPreferenceRequiresPolicy(t *testing.T) {
+ s := &memoryPreferenceStore{preferences: Preferences{MainTab: "quota", QuotaView: "thresholds"}}
+ m := NewWithPreferences("aStepTestFetcher{steps: []codex.QuotaStep{{Threshold: 80}}}, time.Minute, s)
+ if m.meterView != viewThresholds || m.currentMainTab() != mainTabQuota {
+ t.Fatal("Thresholds quota view was not restored")
+ }
+ m = NewWithPreferences(nil, time.Minute, s)
+ if m.meterView != viewBars {
+ t.Fatal("unavailable Thresholds did not fall back to Bars")
+ }
+}
+
func TestRestoredTabsLoadDataWithoutStartingBenchmark(t *testing.T) {
tasks := make(chan []codex.BenchmarkTaskID, 1)
m := NewWithPreferences(benchmarkCaptureFetcher{tasks: tasks}, time.Minute, &memoryPreferenceStore{preferences: Preferences{MainTab: "benchmark"}})
diff --git a/internal/ui/tabs.go b/internal/ui/tabs.go
index 8f0d8dd..ec10a9a 100644
--- a/internal/ui/tabs.go
+++ b/internal/ui/tabs.go
@@ -20,14 +20,6 @@ const (
mainTabCount
)
-func (t mainTabID) next() mainTabID {
- return (t + 1) % mainTabCount
-}
-
-func (t mainTabID) previous() mainTabID {
- return (t - 1 + mainTabCount) % mainTabCount
-}
-
type mainTab struct {
tab mainTabID
label string
@@ -75,33 +67,44 @@ func mainTabLayout(width int, showMonitorLight bool) ([]mainTab, string) {
monitorMinimal = "[S●]"
microMonitor = "●"
}
- labels, separator := responsiveTabLabels(width, [][]string{
+ ids := []mainTabID{mainTabQuota, mainTabMonitor, mainTabUsage, mainTabBenchmark}
+ tiers := [][]string{
{i18n.Text("╭ QUOTA ╮"), monitorFull, i18n.Text("╭ USAGE ╮"), i18n.Text("╭ BENCHMARK ╮")},
{"╭QTA╮", monitorCompact, "╭USE╮", "╭TEST╮"},
{"[Q]", monitorMinimal, "[U]", "[B]"},
{"Q", microMonitor, "U", "B"},
- })
+ }
+ labels, separator := responsiveTabLabels(width, tiers)
- tabs := make([]mainTab, 0, mainTabCount)
+ tabs := make([]mainTab, 0, len(ids))
x := 0
- for tab, label := range labels {
+ for index, label := range labels {
tabWidth := lipgloss.Width(label)
if x+tabWidth > width {
break
}
- tabs = append(tabs, mainTab{tab: mainTabID(tab), label: label, x: x, width: tabWidth})
+ tabs = append(tabs, mainTab{tab: ids[index], label: label, x: x, width: tabWidth})
x += tabWidth + len(separator)
}
return tabs, separator
}
-func quotaViewTabLayout(width int) ([]viewTab, string) {
- labels, separator := responsiveTabLabels(width, [][]string{
+func quotaViewTabLayout(width int, thresholds ...bool) ([]viewTab, string) {
+ order := append([]meterViewID(nil), quotaViewOrder[:]...)
+ tiers := [][]string{
{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"},
- })
+ }
+ if len(thresholds) > 0 && thresholds[0] {
+ order = append(order, viewThresholds)
+ labels := []string{"╭ " + i18n.Text("THRESHOLDS") + " ╮", "╭STEP╮", "[T]", "T"}
+ for i := range tiers {
+ tiers[i] = append(tiers[i], labels[i])
+ }
+ }
+ labels, separator := responsiveTabLabels(width, tiers)
tabs := make([]viewTab, 0, len(quotaViewOrder))
x := 0
@@ -110,7 +113,7 @@ func quotaViewTabLayout(width int) ([]viewTab, string) {
if x+tabWidth > width {
break
}
- tabs = append(tabs, viewTab{view: quotaViewOrder[index], label: label, x: x, width: tabWidth})
+ tabs = append(tabs, viewTab{view: order[index], label: label, x: x, width: tabWidth})
x += tabWidth + len(separator)
}
return tabs, separator
@@ -190,7 +193,7 @@ func (m Model) renderMainTabs(width int, colors palette) string {
}
func (m Model) renderQuotaViewTabs(width int, colors palette) string {
- tabs, separator := quotaViewTabLayout(width)
+ tabs, separator := quotaViewTabLayout(width, len(m.quotaSteps) > 0)
parts := make([]string, 0, len(tabs))
used := 0
for _, tab := range tabs {
@@ -278,6 +281,17 @@ func (m Model) mainTabAt(x, y int) (mainTabID, bool) {
return mainTabQuota, false
}
+func (m Model) adjacentMainTab(direction int) mainTabID {
+ tabs := []mainTabID{mainTabQuota, mainTabMonitor, mainTabUsage, mainTabBenchmark}
+ current := m.currentMainTab()
+ for index, tab := range tabs {
+ if tab == current {
+ return tabs[(index+direction+len(tabs))%len(tabs)]
+ }
+ }
+ return mainTabQuota
+}
+
func (m Model) quotaViewTabAt(x, y int) (meterViewID, bool) {
if x < 0 || y < 0 || !m.meterView.isQuota() || (m.loading && len(m.snapshot.Meters()) == 0) {
return viewBars, false
@@ -287,7 +301,7 @@ func (m Model) quotaViewTabAt(x, y int) (meterViewID, bool) {
return viewBars, false
}
localX := x - 2
- tabs, _ := quotaViewTabLayout(layout.contentWidth)
+ tabs, _ := quotaViewTabLayout(layout.contentWidth, len(m.quotaSteps) > 0)
for _, tab := range tabs {
if localX >= tab.x && localX < tab.x+tab.width {
return tab.view, true
diff --git a/internal/ui/tabs_test.go b/internal/ui/tabs_test.go
index bcbfa7d..82bbe7d 100644
--- a/internal/ui/tabs_test.go
+++ b/internal/ui/tabs_test.go
@@ -41,6 +41,33 @@ func TestMainTabsChooseResponsiveLabels(t *testing.T) {
}
}
+func TestThresholdTabOnlyAppearsWithConfiguredSteps(t *testing.T) {
+ without, _ := quotaViewTabLayout(160, false)
+ with, _ := quotaViewTabLayout(160, true)
+ if len(without) != 5 || len(with) != 6 || with[5].view != viewThresholds || with[4].view != viewResets {
+ t.Fatal("Thresholds must follow Resets only when configured")
+ }
+ m := Model{meterView: viewResets, quotaSteps: []codex.QuotaStep{{Threshold: 80}}}
+ updated, _ := m.Update(key('v'))
+ m = updated.(Model)
+ if m.meterView != viewThresholds || m.currentMainTab() != mainTabQuota {
+ t.Fatal("V did not enter Quota Thresholds")
+ }
+ updated, _ = m.Update(specialKey(tea.KeyTab))
+ m = updated.(Model)
+ if m.meterView != viewMonitor {
+ t.Fatal("Tab did not enter Sessions")
+ }
+ updated, _ = m.pressMainTab(mainTabQuota)
+ if updated.(Model).meterView != viewThresholds {
+ t.Fatal("Quota lost Thresholds selection")
+ }
+ updated, _ = updated.(Model).Update(key('v'))
+ if updated.(Model).meterView != viewBars {
+ t.Fatal("V did not wrap to Bars")
+ }
+}
+
func TestSessionsBrandingAndLegacyPreference(t *testing.T) {
store := &memoryPreferenceStore{preferences: Preferences{MainTab: "monitor", QuotaView: "pie"}}
m := NewWithPreferences(nil, time.Minute, store)
diff --git a/internal/ui/theme.go b/internal/ui/theme.go
index d544cf0..d54af83 100644
--- a/internal/ui/theme.go
+++ b/internal/ui/theme.go
@@ -34,6 +34,7 @@ const (
viewBenchmark
viewUsage
viewResets
+ viewThresholds
viewCount
)
@@ -46,6 +47,9 @@ var quotaViewOrder = [...]meterViewID{
}
func (s meterViewID) isQuota() bool {
+ if s == viewThresholds {
+ return true
+ }
for _, view := range quotaViewOrder {
if s == view {
return true
@@ -54,7 +58,10 @@ func (s meterViewID) isQuota() bool {
return false
}
-func (s meterViewID) nextQuota() meterViewID {
+func (s meterViewID) nextQuota(thresholds ...bool) meterViewID {
+ if s == viewResets && len(thresholds) > 0 && thresholds[0] {
+ return viewThresholds
+ }
for index, view := range quotaViewOrder {
if s == view {
return quotaViewOrder[(index+1)%len(quotaViewOrder)]
@@ -73,6 +80,7 @@ func (s meterViewID) name() string {
i18n.Text("BENCHMARK"),
i18n.Text("USAGE"),
i18n.Text("RESETS"),
+ i18n.Text("THRESHOLDS"),
}[s]
}
diff --git a/internal/ui/thresholds.go b/internal/ui/thresholds.go
new file mode 100644
index 0000000..f0c4a20
--- /dev/null
+++ b/internal/ui/thresholds.go
@@ -0,0 +1,79 @@
+package ui
+
+import (
+ "fmt"
+ "strings"
+
+ "charm.land/lipgloss/v2"
+ "github.com/charmbracelet/x/ansi"
+
+ "github.com/merefield/codexometer/internal/codex"
+ "github.com/merefield/codexometer/internal/i18n"
+)
+
+func (m Model) thresholdDetailLines(width int, colors palette) []string {
+ statuses, used := codex.QuotaStepStatuses(m.snapshot, m.quotaSteps)
+
+ lines := []string{colors.dimmed().Render(i18n.Text("MODEL STEP POLICY"))}
+ if used == nil {
+ lines = append(lines, colors.dimmed().Render(i18n.Text("QUOTA WINDOW UNAVAILABLE")))
+ } else {
+ lines = append(lines, colors.label().Render(i18n.Format("QUOTA WINDOW // %d%% USED", *used)))
+ }
+ lines = append(lines, "")
+
+ wide := width >= 72
+ if wide {
+ lines = append(lines, colors.dimmed().Render(fitTableCell(i18n.Text("TRIGGER"), 10)+fitTableCell(i18n.Text("MODEL / REASONING LEVEL / SPEED"), width-40)+fitTableCell(i18n.Text("ACTION"), 10)+fitTableCell(i18n.Text("STATE"), 20)))
+ }
+ for _, status := range statuses {
+ step := status.Step
+ speed := step.ServiceTier
+ if speed == "" {
+ speed = i18n.Text("speed unchanged")
+ } else if speed == "default" {
+ speed = "standard"
+ }
+ mode := i18n.Text("ASK")
+ if step.Mode == "auto" {
+ mode = i18n.Text("AUTO")
+ }
+ state := i18n.Text(string(status.Stage))
+ style := colors.dimmed()
+ switch status.Stage {
+ case codex.QuotaStepActive:
+ style = colors.label()
+ case codex.QuotaStepNext:
+ state, style = i18n.Format("NEXT // %d PP", status.Remaining), colors.label()
+ }
+ profile := codex.SanitizeSessionContext(step.Model + " / " + step.Effort + " / " + speed)
+ if wide {
+ line := fitTableCell(fmt.Sprintf("%d%%", step.Threshold), 10) + fitTableCell(profile, width-40) + fitTableCell(mode, 10) + fitTableCell(state, 20)
+ lines = append(lines, style.Render(ansi.Truncate(line, width, "")))
+ } else {
+ lines = append(lines,
+ style.Render(fmt.Sprintf("%d%% // %s // %s", step.Threshold, mode, state)),
+ colors.dimmed().Render(ansi.Truncate(profile, width, "")),
+ )
+ }
+ }
+ return strings.Split(ansi.Hardwrap(strings.Join(lines, "\n"), max(width, 1), true), "\n")
+}
+
+func (m Model) renderThresholds(width, height int, colors palette) string {
+ lines := m.thresholdDetailLines(max(width-4, 1), colors)
+ rows := max(height-2, 1)
+ start := min(m.thresholdScroll, max(len(lines)-rows, 0))
+ title := i18n.Text("THRESHOLDS")
+ if len(lines) > rows {
+ title += " // ↑↓ PgUp/PgDn"
+ }
+ body := strings.Join(lines[start:min(start+rows, len(lines))], "\n")
+ return lipgloss.NewStyle().MaxWidth(width).MaxHeight(height).Render(frameSized(width, rows, title, body, colors.primary, colors))
+}
+
+func (m *Model) scrollThresholds(delta int) {
+ g := m.dashboardLayout()
+ limit := max(len(m.thresholdDetailLines(max(g.contentWidth-4, 1), paletteFor(m.theme)))-max(g.meterHeight-2, 1), 0)
+ m.thresholdScroll = min(max(m.thresholdScroll+delta, 0), limit)
+}
diff --git a/internal/ui/thresholds_test.go b/internal/ui/thresholds_test.go
new file mode 100644
index 0000000..8c6f936
--- /dev/null
+++ b/internal/ui/thresholds_test.go
@@ -0,0 +1,73 @@
+package ui
+
+import (
+ "strings"
+ "testing"
+
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+ "github.com/charmbracelet/x/ansi"
+ "github.com/merefield/codexometer/internal/codex"
+)
+
+func TestThresholdsRenderActiveAndNextPolicySteps(t *testing.T) {
+ snapshot := codex.DemoSnapshot()
+ snapshot.AccountFingerprint = "account"
+ snapshot.RateLimits.Secondary.UsedPercent = 65
+ m := Model{snapshot: snapshot, quotaSteps: []codex.QuotaStep{
+ {Threshold: 80, Model: "gpt-small", Effort: "low", Mode: "auto"},
+ {Threshold: 50, Model: "gpt-medium", Effort: "medium", ServiceTier: "fast"},
+ }}
+ output := ansi.Strip(m.renderThresholds(100, 16, paletteFor(themeHacker)))
+ for _, want := range []string{"THRESHOLDS", "50%", "ACTIVE", "gpt-medium / medium / fast", "80%", "AUTO", "NEXT // 15 PP"} {
+ if !strings.Contains(output, want) {
+ t.Fatalf("threshold view missing %q:\n%s", want, output)
+ }
+ }
+}
+
+func TestThresholdNavigationHitboxesAndScroll(t *testing.T) {
+ for _, width := range []int{20, 40, 80, 120} {
+ m := Model{width: width, height: 16, snapshot: codex.DemoSnapshot(), quotaSteps: []codex.QuotaStep{{Threshold: 80, Model: "small", Effort: "low"}}}
+ g := m.dashboardLayout()
+ tabs, _ := quotaViewTabLayout(g.contentWidth, true)
+ for _, tab := range tabs {
+ for x := tab.x; x < tab.x+tab.width; x++ {
+ if got, ok := m.quotaViewTabAt(x+2, g.quotaTabsY); !ok || got != tab.view {
+ t.Fatalf("width %d: tab %d hitbox mismatch at %d", width, tab.view, x)
+ }
+ }
+ }
+ m.meterView = viewThresholds
+ for i := 1; i < 20; i++ {
+ m.quotaSteps = append(m.quotaSteps, codex.QuotaStep{Threshold: i, Model: "model", Effort: "medium"})
+ }
+ next, _ := m.Update(specialKey(tea.KeyPgDown))
+ if next.(Model).thresholdScroll == 0 {
+ t.Fatal("Page Down did not scroll overflowing thresholds")
+ }
+ }
+}
+
+func TestThresholdsRenderCompactAndStayWithinBounds(t *testing.T) {
+ m := Model{quotaSteps: []codex.QuotaStep{{Threshold: 80, Model: "gpt-very-long-model-name", Effort: "medium"}}}
+ output := m.renderThresholds(36, 8, paletteFor(themeHacker))
+ if lipgloss.Width(output) > 36 || strings.Count(output, "\n")+1 > 8 {
+ t.Fatalf("compact threshold view exceeded 36x8:\n%s", ansi.Strip(output))
+ }
+}
+
+func TestThresholdSpeedDisplay(t *testing.T) {
+ for _, tc := range []struct{ tier, want string }{
+ {"default", "standard"}, {"", "speed unchanged"}, {"fast", "fast"}, {"priority", "priority"},
+ } {
+ m := Model{quotaSteps: []codex.QuotaStep{{Threshold: 80, Model: "model", Effort: "low", ServiceTier: tc.tier}}}
+ output := ansi.Strip(m.renderThresholds(120, 16, paletteFor(themeHacker)))
+ if !strings.Contains(output, "model / low / "+tc.want) {
+ t.Errorf("tier %q: missing displayed speed %q in %s", tc.tier, tc.want, output)
+ }
+ if m.quotaSteps[0].ServiceTier != tc.tier {
+ t.Fatal("presentation changed configured tier")
+ }
+ }
+}
diff --git a/internal/ui/view.go b/internal/ui/view.go
index ac7771e..ff22df8 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() && m.meterView != viewResets {
+ if m.meterView.isQuota() && m.meterView != viewResets && m.meterView != viewThresholds {
meters = m.quotaMetersWithInsights(contentWidth)
}
- if len(meters) == 0 && m.meterView != viewUsage && m.meterView != viewResets {
+ if len(meters) == 0 && m.meterView != viewUsage && m.meterView != viewResets && m.meterView != viewThresholds {
emptyView := renderError(contentWidth, fmt.Errorf("no quota windows returned"), colors)
parts = append(parts, emptyView)
}
footer := m.renderFooter(contentWidth, colors)
- if m.meterView == viewResets {
+ if m.meterView == viewThresholds {
+ parts = append(parts, m.renderThresholds(contentWidth, layout.meterHeight, colors))
+ } else 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))
@@ -181,7 +183,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() && m.meterView != viewResets) {
+ if m.meterView == viewBenchmark || (m.meterView.isQuota() && m.meterView != viewResets && m.meterView != viewThresholds) {
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 12280c6..fec5b1c 100644
--- a/internal/ui/view_test.go
+++ b/internal/ui/view_test.go
@@ -24,6 +24,9 @@ func TestViewRendersEveryThemeAndViewWithinStandardTerminal(t *testing.T) {
theme: theme,
meterView: view,
}
+ if view == viewThresholds {
+ model.quotaSteps = []codex.QuotaStep{{Threshold: 50, Model: "gpt-5.6-sol", Effort: "medium"}}
+ }
output := ansi.Strip(model.render())
if !strings.Contains(output, paletteFor(theme).name) {
t.Errorf("theme %d name missing from view", theme)
@@ -47,6 +50,10 @@ func TestViewRendersEveryThemeAndViewWithinStandardTerminal(t *testing.T) {
if !strings.Contains(output, "LIFETIME") {
t.Error("usage summary missing")
}
+ } else if view == viewThresholds {
+ if !strings.Contains(output, "MODEL STEP POLICY") || !strings.Contains(output, "gpt-5.6-sol") {
+ t.Error("threshold policy missing")
+ }
} else if !strings.Contains(output, "5 HOURS LOOP") || !strings.Contains(output, "1 WEEK LOOP") {
t.Errorf("quota windows missing for theme %d view %d", theme, view)
}
diff --git a/internal/web/dist/assets/index-11iTtNNT.js b/internal/web/dist/assets/index-11iTtNNT.js
new file mode 100644
index 0000000..c85fa4b
--- /dev/null
+++ b/internal/web/dist/assets/index-11iTtNNT.js
@@ -0,0 +1,30 @@
+(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var e=Array.isArray,t=Array.prototype.indexOf,n=Array.prototype.includes,r=Array.from,i=Object.defineProperty,a=Object.getOwnPropertyDescriptor,o=Object.getOwnPropertyDescriptors,s=Object.prototype,c=Array.prototype,l=Object.getPrototypeOf,u=Object.isExtensible;function d(e){return typeof e==`function`}var f=()=>{};function p(e){return e()}function m(e){for(var t=0;t Quota refresh failed. Policy state is based on the last successful
+ observation. The longest Codex quota window selects one active model profile. ASK
+ creates a per-session review; AUTO applies the profile on the next
+ eligible check. No threshold-based model steps were configured at launch. Quota refresh failed. Values below are the last successful observation. Expiry details unavailable. No listed expiry does not mean no expiry. Read-only preview. Use the terminal to redeem a reset. The backend may return only some credits. This list does not establish
+ redemption order. Cycle duration or reset date unavailable — position cannot be
+ plotted. Cycle duration unavailable — pace cannot be calculated. No quota windows reported yet. All reported windows are shown. API-equivalent learning and quota status
+ scoring remain in the terminal for this first preview. MODEL / REASONING LEVEL / SPEED MODEL / REASONING LEVEL / SPEED Applied settings remain after Codexometer closes. Command unavailable from this observation. Open Codex to inspect the
+ request. Session controls temporarily unavailable. Check Codex for current state. Checking session controls… Controls require a supported live request from a connected shared
+ app-server session. Local observations alone cannot provide them. Type one of these choices exactly. Your answer stays masked. Command unavailable from this observation. Open Codex to inspect the
+ request. Session connection or refresh unavailable. Context and telemetry may be
+ stale. Some quota profile checks are unavailable. Only sessions with freshly
+ verified quota and settings can be updated; previous outcome notices remain
+ visible. Read only — reply or approve in Codex. This session is no longer in the current observation. Return to sessions. No locally observed sessions yet. Keep Codex running alongside
+ Codexometer. ↑ ↓ SELECT SESSION // ← LESS DETAIL // → MORE DETAIL // ESC BACK History refresh failed. Any displayed history is the last successful
+ observation. LESS ░ ▒ ▓ █ MORE // HOVER FOR DATE AND TOKENS DAYS History unavailable or awaiting a matching account observation. Missing
+ history is not treated as zero usage. Account-wide history reported by Codex, not the local Sessions counter. Dates
+ use UTC. Historical resets are not provided by this data. Connecting to your local Codexometer… Your quota. Your sessions. Your command centre. Quota refresh failed. Values below are the last successful observation. Expiry details unavailable. No listed expiry does not mean no expiry. Read-only preview. Use the terminal to redeem a reset. The backend may return only some credits. This list does not establish
- redemption order. Cycle duration or reset date unavailable — position cannot be
- plotted. Cycle duration unavailable — pace cannot be calculated. No quota windows reported yet. All reported windows are shown. API-equivalent learning and quota status
- scoring remain in the terminal for this first preview. MODEL / REASONING LEVEL / SPEED MODEL / REASONING LEVEL / SPEED Applied settings remain after Codexometer closes. Command unavailable from this observation. Open Codex to inspect the
- request. Session controls temporarily unavailable. Check Codex for current state. Checking session controls… Controls require a supported live request from a connected shared
- app-server session. Local observations alone cannot provide them. Type one of these choices exactly. Your answer stays masked. Command unavailable from this observation. Open Codex to inspect the
- request. Session connection or refresh unavailable. Context and telemetry may be
- stale. Some quota profile checks are unavailable. Only sessions with freshly
- verified quota and settings can be updated; previous outcome notices remain
- visible. Read only — reply or approve in Codex. This session is no longer in the current observation. Return to sessions. No locally observed sessions yet. Keep Codex running alongside
- Codexometer. ↑ ↓ SELECT SESSION // ← LESS DETAIL // → MORE DETAIL // ESC BACK History refresh failed. Any displayed history is the last successful
- observation. LESS ░ ▒ ▓ █ MORE // HOVER FOR DATE AND TOKENS DAYS History unavailable or awaiting a matching account observation. Missing
- history is not treated as zero usage. Account-wide history reported by Codex, not the local Sessions counter. Dates
- use UTC. Historical resets are not provided by this data. Connecting to your local Codexometer… Your quota. Your sessions. Your command centre.0){var E=i&4&&s===0?n:null;if(o){for(v=0;v
`),va=K(` Observed at Period elapsed Consumed Trail segment
Observed quota
+ path, not individual session usage. Gaps are not interpolated. Expand the
+ observation table for times, positions and gaps.OBSERVATION TABLE
CURRENT PROFILE
PROPOSED PROFILE
`,1),Xa=K(`About browser controls
`),ro=K(`Grants permission beyond this one command. Check the scope
+ carefully.`),io=K(``),ao=K(``),oo=K(``),so=K(``),co=K(` `,1),lo=K(`View fixed choices
`),So=K(`
`),Co=K(`
`,1),To=K(`
`,1),Do=K(`
TOKEN ACTIVITY // 30 SECOND SAMPLES
SESSION TOTALS
`),os=K(` LIFETIME TOKENS
PEAK DAY
CURRENT STREAK
Accessible data table
Date (UTC) Tokens USAGE // ACCOUNT HISTORY
Page not found
`,1);function ds(e){var t=us();Ee(2),q(e,t)}var fs=K(` `),ps=K(`0){var E=i&4&&s===0?n:null;if(o){for(v=0;v `),sa=K(` Observed at Period elapsed Consumed Trail segment
Observed quota
- path, not individual session usage. Gaps are not interpolated. Expand the
- observation table for times, positions and gaps.OBSERVATION TABLE
CURRENT PROFILE
PROPOSED PROFILE
`,1),za=K(`About browser controls
`),Ka=K(`Grants permission beyond this one command. Check the scope
- carefully.`),qa=K(``),Ja=K(``),Ya=K(``),Xa=K(``),Za=K(` `,1),Qa=K(`View fixed choices
`),uo=K(`
`),fo=K(`
`,1),mo=K(`
`,1),go=K(`
TOKEN ACTIVITY // 30 SECOND SAMPLES
SESSION TOTALS
`),Yo=K(` LIFETIME TOKENS
PEAK DAY
CURRENT STREAK
Accessible data table
Date (UTC) Tokens USAGE // ACCOUNT HISTORY
Page not found
`,1);function es(e){var t=$o();Te(2),q(e,t)}var ts=K(` `),ns=K(`
QUOTA // OBSERVED {date(live.data.quotaAt)}
- {#if view === 'resets'} + {#if view === 'thresholds'} +Read-only preview. Use the terminal to redeem a reset.
diff --git a/web/src/Thresholds.svelte b/web/src/Thresholds.svelte new file mode 100644 index 0000000..b8266c3 --- /dev/null +++ b/web/src/Thresholds.svelte @@ -0,0 +1,44 @@ + + +{#if live.data?.thresholds?.length} +MODEL STEP POLICY // OBSERVED {date(live.data.quotaAt)}
+ {#if live.data.quotaError}+ Quota refresh failed. Policy state is based on the last successful + observation. +
{/if} ++ The longest Codex quota window selects one active model profile. ASK + creates a per-session review; AUTO applies the profile on the next + eligible check. +
++ {threshold.effort.toUpperCase()} REASONING // {threshold.speed.toUpperCase()} + SPEED +
+No threshold-based model steps were configured at launch.
+{/if} diff --git a/web/src/preferences.svelte.ts b/web/src/preferences.svelte.ts index 3c65d88..f01944d 100644 --- a/web/src/preferences.svelte.ts +++ b/web/src/preferences.svelte.ts @@ -1,4 +1,12 @@ -export const quotaViews = ['bars', 'pace', 'zone', 'pie', 'fuel', 'resets']; +export const quotaViews = [ + 'bars', + 'pace', + 'zone', + 'pie', + 'fuel', + 'resets', + 'thresholds', +]; const key = 'codexometer.web.preferences.v1'; interface Preferences { tab: 'quota' | 'sessions' | 'usage'; diff --git a/web/src/state.svelte.ts b/web/src/state.svelte.ts index 554edd4..9158aea 100644 --- a/web/src/state.svelte.ts +++ b/web/src/state.svelte.ts @@ -38,6 +38,15 @@ export interface Usage { }; dailyUsageBuckets: { startDate: string; tokens: number }[] | null; } +export interface Threshold { + threshold: number; + model: string; + effort: string; + speed: string; + mode: string; + state: string; + remaining?: number; +} export interface Snapshot { profiles?: { session: string; @@ -48,6 +57,7 @@ export interface Snapshot { notice?: string; }[]; profileError?: boolean; + thresholds?: Threshold[]; control?: boolean; version: string; meters: Meter[]; diff --git a/web/src/style.css b/web/src/style.css index 59d0794..0c66a41 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -531,6 +531,57 @@ hr { gap: 16px; margin: 20px 0; } +.thresholds-panel { + padding: clamp(14px, 2vw, 22px); +} +.thresholds-panel h1 { + margin-top: 0; +} +.threshold-list { + display: grid; + gap: 8px; + margin-top: 16px; +} +.threshold-list article { + display: grid; + grid-template-columns: + minmax(64px, 0.5fr) minmax(220px, 3fr) minmax(64px, 0.6fr) + minmax(120px, 1.2fr); + align-items: center; + gap: 12px; + border: 1px solid var(--edge); + padding: 10px 12px; +} +.threshold-list article.active { + border-color: var(--accent); + box-shadow: inset 3px 0 0 var(--accent); +} +.threshold-list article.next { + border-color: var(--approval); + box-shadow: inset 3px 0 0 var(--approval); +} +.threshold-list p { + margin: 3px 0 0; + color: var(--muted); + font-size: 12px; +} +.threshold-trigger { + color: var(--accent); + font-size: 22px; + font-weight: 800; +} +.threshold-mode, +.threshold-state { + color: var(--muted); + font-size: 12px; + font-weight: 700; +} +.threshold-list article.active .threshold-state { + color: var(--accent); +} +.threshold-list article.next .threshold-state { + color: var(--approval); +} .heat-scroll { overflow-x: auto; } @@ -591,6 +642,13 @@ footer select { .connection { text-align: left; } + .threshold-list article { + grid-template-columns: 64px minmax(0, 1fr); + } + .threshold-mode, + .threshold-state { + grid-column: 2; + } } @media (prefers-reduced-motion: reduce) { *, diff --git a/web/tests/browser.spec.ts b/web/tests/browser.spec.ts index 6336d3b..6b7f564 100644 --- a/web/tests/browser.spec.ts +++ b/web/tests/browser.spec.ts @@ -104,6 +104,28 @@ test.describe('quota profile reviews', () => { pairingURL, }) => { await page.goto(pairingURL); + const thresholds = page.getByRole('link', { + name: 'THRESHOLDS', + exact: true, + }); + await expect(thresholds).toBeVisible(); + await expect( + page + .getByRole('navigation', { name: 'Main navigation' }) + .getByRole('link', { name: 'THRESHOLDS' }), + ).toHaveCount(0); + await expect( + page + .getByRole('navigation', { name: 'Quota view' }) + .getByRole('link') + .last(), + ).toHaveText('THRESHOLDS'); + await thresholds.click(); + await expect( + page.getByRole('heading', { name: /THRESHOLDS/ }), + ).toBeVisible(); + await expect(page.locator('.threshold-list')).toContainText('gpt-5.6-luna'); + await expect(page.locator('.threshold-list')).toContainText('ASK'); await page.getByRole('link', { name: 'SESSIONS', exact: true }).click(); const pill = page.getByRole('link', { name: /QUOTA THRESHOLD/ }).first(); await expect(pill).toBeVisible({ timeout: 15000 }); @@ -171,6 +193,16 @@ test.describe('quota profile reviews', () => { }); }); +test('Thresholds navigation stays hidden without a launch policy', async ({ + page, + pairingURL, +}) => { + await page.goto(pairingURL); + await expect( + page.getByRole('link', { name: 'THRESHOLDS', exact: true }), + ).toHaveCount(0); +}); + test('read-only session copy captures working prose in every detail level without server writes', async ({ page, pairingURL,