From b37dd81d5dd9d638c4cc7d47062c6f4f06d03261 Mon Sep 17 00:00:00 2001 From: merefield Date: Fri, 18 Sep 2026 09:25:02 +0100 Subject: [PATCH 1/9] FEAT: move quota threshold reviews into session detail --- README.md | 21 ++- internal/i18n/locales/da.json | 21 +-- internal/i18n/locales/de.json | 21 +-- internal/i18n/locales/en-GB.json | 21 +-- internal/i18n/locales/es.json | 21 +-- internal/i18n/locales/et.json | 21 +-- internal/i18n/locales/fi.json | 21 +-- internal/i18n/locales/fr.json | 21 +-- internal/i18n/locales/it.json | 21 +-- internal/i18n/locales/ja.json | 21 +-- internal/i18n/locales/nb.json | 21 +-- internal/i18n/locales/nl.json | 21 +-- internal/i18n/locales/pt-BR.json | 21 +-- internal/i18n/locales/pt-PT.json | 21 +-- internal/i18n/locales/ru.json | 21 +-- internal/i18n/locales/sv.json | 21 +-- internal/i18n/locales/tr.json | 21 +-- internal/i18n/locales/zh-Hans.json | 21 +-- internal/ui/model.go | 38 +--- internal/ui/monitor.go | 4 + internal/ui/monitor_context.go | 35 +++- internal/ui/monitor_context_modes.go | 7 + internal/ui/monitor_copy.go | 2 +- internal/ui/monitor_detail.go | 8 +- internal/ui/monitor_detail_activity.go | 8 + internal/ui/monitor_profile.go | 243 ++++++++++++++++++++++++ internal/ui/monitor_profile_test.go | 225 +++++++++++++++++++++++ internal/ui/monitor_prompt.go | 8 + internal/ui/monitor_summary.go | 22 ++- internal/ui/quota_reset.go | 10 +- internal/ui/quota_step_down.go | 245 +++++++------------------ internal/ui/quota_step_down_test.go | 124 +++---------- internal/ui/view.go | 3 - intro-post.md | 7 + 34 files changed, 775 insertions(+), 592 deletions(-) create mode 100644 internal/ui/monitor_profile.go create mode 100644 internal/ui/monitor_profile_test.go diff --git a/README.md b/README.md index 64c5dd4..85fde05 100644 --- a/README.md +++ b/README.md @@ -2081,13 +2081,20 @@ 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. -The quota/reset action area adds separate profile controls without replacing -the banked-reset button. Controls remain available when no reset credits exist. -At a reached threshold, `G` reviews one loaded session and its current and -proposed settings; repeating `G` within ten seconds approves that exact session. -`N` selects the next session, `D` skips it at this gate, and `Esc` cancels review. -`A` explicitly reviews/approves all listed sessions, only when the review fits -the pane. Newly discovered sessions are never covered by an earlier approval. +At a reached threshold, eligible sessions in the terminal **Sessions** tab get a +**QUOTA THRESHOLD** attention pill. Click it to review that session's current and +proposed model, reasoning and speed in its detail pane. Controls also appear in +split/wide detail when the entire review fits; otherwise open full detail or +enlarge the terminal. The Quota page retains only its existing reset controls. + +Click **APPLY PROFILE** (or press `1` for the selected session), then click +**CONFIRM PROFILE** or press `C` within ten seconds. Click **SKIP** or press `2` +to skip that session at the current threshold; `Esc` cancels confirmation. +Navigation, resizing and inventory refresh disarm confirmations. Each approval +is for exactly one session—there is no bulk approval and newly discovered +sessions need their own review. Codex approval/input requests take priority; +the quota suggestion becomes available once that request is no longer shown. +These profile reviews are terminal-only; the browser interface is unchanged. The shared app-server's experimental `thread/settings/update` changes subsequent turns, not a turn already in progress. A queued acknowledgement alone is not reported as a verified change. Changed settings since review are skipped. diff --git a/internal/i18n/locales/da.json b/internal/i18n/locales/da.json index 4869303..4d77c9d 100644 --- a/internal/i18n/locales/da.json +++ b/internal/i18n/locales/da.json @@ -1,25 +1,16 @@ { + "QUOTA THRESHOLD": "KVOTETÆRSKEL", + "Quota threshold: %d%%": "Kvotetærskel: %d%%", + "Refresh quota before approving.": "Opdater kvoten før godkendelse.", + "[ (1) APPLY PROFILE ]": "[ (1) ANVEND PROFIL ]", + "[ (2) SKIP ]": "[ (2) SPRING OVER ]", + "[ (C) CONFIRM PROFILE ]": "[ (C) BEKRÆFT PROFIL ]", "speed unchanged": "hastighed uændret", - "Quota window changed. Session settings unchanged.": "Kvoteperioden er ændret. Sessionsindstillingerne er uændrede.", "Session controls unavailable.": "Sessionsstyring er ikke tilgængelig.", - "Review %d session(s). Repeat the same action to confirm; Esc cancels.": "Gennemgå %d session(er). Gentag samme handling for at bekræfte; Esc annullerer.", - "Review sessions individually with G; this pane cannot show all.": "Gennemgå sessioner enkeltvis med G; panelet kan ikke vise alle.", "Session skipped for this threshold.": "Sessionen blev sprunget over for denne tærskel.", - "QUOTA PROFILE": "KVOTEPROFIL", "Checking / updating…": "Kontrollerer / opdaterer…", - "Review session settings.": "Gennemgå sessionsindstillingerne.", - "Approved profile: %s": "Godkendt profil: %s", - "Waiting for threshold / new sessions.": "Venter på tærskel / nye sessioner.", "Changes remain after Codexometer closes.": "Ændringerne bevares, når Codexometer lukkes.", "unset": "ikke angivet", - "Gate %d%% — Session %d/%d: %s": "Tærskel %d%% — Session %d/%d: %s", - "[G: REVIEW ONE]": "[G: GENNEMGÅ ÉN]", - "[A: REVIEW ALL]": "[A: GENNEMGÅ ALLE]", - "[N: NEXT]": "[N: NÆSTE]", - "[D: SKIP]": "[D: SPRING OVER]", - "[G: CONFIRM ONE]": "[G: BEKRÆFT ÉN]", - "[A: CONFIRM ALL]": "[A: BEKRÆFT ALLE]", - "%d session(s) already at target; no approval needed.": "%d session(er) har allerede målindstillingerne; ingen godkendelse nødvendig.", "Session check failed: %s": "Sessionskontrol mislykkedes: %s", "Verified %d of %d session updates. No automatic retries.": "Verificeret %d af %d sessionsopdateringer. Ingen automatiske genforsøg.", "Check Codex: %s": "Kontrollér Codex: %s", diff --git a/internal/i18n/locales/de.json b/internal/i18n/locales/de.json index c74d199..e6dd25f 100644 --- a/internal/i18n/locales/de.json +++ b/internal/i18n/locales/de.json @@ -1,25 +1,16 @@ { + "QUOTA THRESHOLD": "KONTINGENTSCHWELLE", + "Quota threshold: %d%%": "Kontingentschwelle: %d%%", + "Refresh quota before approving.": "Kontingent vor der Bestätigung aktualisieren.", + "[ (1) APPLY PROFILE ]": "[ (1) PROFIL ANWENDEN ]", + "[ (2) SKIP ]": "[ (2) ÜBERSPRINGEN ]", + "[ (C) CONFIRM PROFILE ]": "[ (C) PROFIL BESTÄTIGEN ]", "speed unchanged": "Geschwindigkeit unverändert", - "Quota window changed. Session settings unchanged.": "Kontingentzeitraum geändert. Sitzungseinstellungen unverändert.", "Session controls unavailable.": "Sitzungssteuerung nicht verfügbar.", - "Review %d session(s). Repeat the same action to confirm; Esc cancels.": "%d Sitzung(en) prüfen. Dieselbe Aktion zum Bestätigen wiederholen; Esc bricht ab.", - "Review sessions individually with G; this pane cannot show all.": "Sitzungen einzeln mit G prüfen; dieses Fenster kann nicht alle anzeigen.", "Session skipped for this threshold.": "Sitzung für diesen Schwellenwert übersprungen.", - "QUOTA PROFILE": "KONTINGENTPROFIL", "Checking / updating…": "Prüfen / aktualisieren…", - "Review session settings.": "Sitzungseinstellungen prüfen.", - "Approved profile: %s": "Genehmigtes Profil: %s", - "Waiting for threshold / new sessions.": "Warten auf Schwellenwert / neue Sitzungen.", "Changes remain after Codexometer closes.": "Änderungen bleiben nach dem Schließen von Codexometer bestehen.", "unset": "nicht festgelegt", - "Gate %d%% — Session %d/%d: %s": "Schwelle %d%% — Sitzung %d/%d: %s", - "[G: REVIEW ONE]": "[G: EINE PRÜFEN]", - "[A: REVIEW ALL]": "[A: ALLE PRÜFEN]", - "[N: NEXT]": "[N: WEITER]", - "[D: SKIP]": "[D: ÜBERSPRINGEN]", - "[G: CONFIRM ONE]": "[G: EINE BESTÄTIGEN]", - "[A: CONFIRM ALL]": "[A: ALLE BESTÄTIGEN]", - "%d session(s) already at target; no approval needed.": "%d Sitzung(en) bereits mit Zieleinstellungen; keine Genehmigung nötig.", "Session check failed: %s": "Sitzungsprüfung fehlgeschlagen: %s", "Verified %d of %d session updates. No automatic retries.": "%d von %d Sitzungsaktualisierungen verifiziert. Keine automatischen Wiederholungen.", "Check Codex: %s": "Codex prüfen: %s", diff --git a/internal/i18n/locales/en-GB.json b/internal/i18n/locales/en-GB.json index 7f6c329..e7625d3 100644 --- a/internal/i18n/locales/en-GB.json +++ b/internal/i18n/locales/en-GB.json @@ -1,25 +1,16 @@ { + "QUOTA THRESHOLD": "QUOTA THRESHOLD", + "Quota threshold: %d%%": "Quota threshold: %d%%", + "Refresh quota before approving.": "Refresh quota before approving.", + "[ (1) APPLY PROFILE ]": "[ (1) APPLY PROFILE ]", + "[ (2) SKIP ]": "[ (2) SKIP ]", + "[ (C) CONFIRM PROFILE ]": "[ (C) CONFIRM PROFILE ]", "speed unchanged": "speed unchanged", - "Quota window changed. Session settings unchanged.": "Quota window changed. Session settings unchanged.", "Session controls unavailable.": "Session controls unavailable.", - "Review %d session(s). Repeat the same action to confirm; Esc cancels.": "Review %d session(s). Repeat the same action to confirm; Esc cancels.", - "Review sessions individually with G; this pane cannot show all.": "Review sessions individually with G; this pane cannot show all.", "Session skipped for this threshold.": "Session skipped for this threshold.", - "QUOTA PROFILE": "QUOTA PROFILE", "Checking / updating…": "Checking / updating…", - "Review session settings.": "Review session settings.", - "Approved profile: %s": "Approved profile: %s", - "Waiting for threshold / new sessions.": "Waiting for threshold / new sessions.", "Changes remain after Codexometer closes.": "Changes remain after Codexometer closes.", "unset": "unset", - "Gate %d%% — Session %d/%d: %s": "Gate %d%% — Session %d/%d: %s", - "[G: REVIEW ONE]": "[G: REVIEW ONE]", - "[A: REVIEW ALL]": "[A: REVIEW ALL]", - "[N: NEXT]": "[N: NEXT]", - "[D: SKIP]": "[D: SKIP]", - "[G: CONFIRM ONE]": "[G: CONFIRM ONE]", - "[A: CONFIRM ALL]": "[A: CONFIRM ALL]", - "%d session(s) already at target; no approval needed.": "%d session(s) already at target; no approval needed.", "Session check failed: %s": "Session check failed: %s", "Verified %d of %d session updates. No automatic retries.": "Verified %d of %d session updates. No automatic retries.", "Check Codex: %s": "Check Codex: %s", diff --git a/internal/i18n/locales/es.json b/internal/i18n/locales/es.json index 99c77c0..a78e937 100644 --- a/internal/i18n/locales/es.json +++ b/internal/i18n/locales/es.json @@ -1,25 +1,16 @@ { + "QUOTA THRESHOLD": "UMBRAL DE CUOTA", + "Quota threshold: %d%%": "Umbral de cuota: %d%%", + "Refresh quota before approving.": "Actualiza la cuota antes de confirmar.", + "[ (1) APPLY PROFILE ]": "[ (1) APLICAR PERFIL ]", + "[ (2) SKIP ]": "[ (2) OMITIR ]", + "[ (C) CONFIRM PROFILE ]": "[ (C) CONFIRMAR PERFIL ]", "speed unchanged": "velocidad sin cambios", - "Quota window changed. Session settings unchanged.": "Período de cuota cambiado. Ajustes de sesión sin cambios.", "Session controls unavailable.": "Controles de sesión no disponibles.", - "Review %d session(s). Repeat the same action to confirm; Esc cancels.": "Revisa %d sesión(es). Repite la misma acción para confirmar; Esc cancela.", - "Review sessions individually with G; this pane cannot show all.": "Revisa las sesiones una a una con G; este panel no puede mostrarlas todas.", "Session skipped for this threshold.": "Sesión omitida para este umbral.", - "QUOTA PROFILE": "PERFIL DE CUOTA", "Checking / updating…": "Comprobando / actualizando…", - "Review session settings.": "Revisa los ajustes de sesión.", - "Approved profile: %s": "Perfil aprobado: %s", - "Waiting for threshold / new sessions.": "Esperando umbral / nuevas sesiones.", "Changes remain after Codexometer closes.": "Los cambios permanecen después de cerrar Codexometer.", "unset": "sin configurar", - "Gate %d%% — Session %d/%d: %s": "Umbral %d%% — Sesión %d/%d: %s", - "[G: REVIEW ONE]": "[G: REVISAR UNA]", - "[A: REVIEW ALL]": "[A: REVISAR TODAS]", - "[N: NEXT]": "[N: SIGUIENTE]", - "[D: SKIP]": "[D: OMITIR]", - "[G: CONFIRM ONE]": "[G: CONFIRMAR UNA]", - "[A: CONFIRM ALL]": "[A: CONFIRMAR TODAS]", - "%d session(s) already at target; no approval needed.": "%d sesión(es) ya tienen los ajustes objetivo; no requieren aprobación.", "Session check failed: %s": "Error al comprobar las sesiones: %s", "Verified %d of %d session updates. No automatic retries.": "Verificadas %d de %d actualizaciones de sesión. Sin reintentos automáticos.", "Check Codex: %s": "Comprueba Codex: %s", diff --git a/internal/i18n/locales/et.json b/internal/i18n/locales/et.json index f996c0d..724cc26 100644 --- a/internal/i18n/locales/et.json +++ b/internal/i18n/locales/et.json @@ -1,25 +1,16 @@ { + "QUOTA THRESHOLD": "KVOODILÄVI", + "Quota threshold: %d%%": "Kvoodilävi: %d%%", + "Refresh quota before approving.": "Värskenda kvooti enne kinnitamist.", + "[ (1) APPLY PROFILE ]": "[ (1) RAKENDA PROFIIL ]", + "[ (2) SKIP ]": "[ (2) JÄTA VAHELE ]", + "[ (C) CONFIRM PROFILE ]": "[ (C) KINNITA PROFIIL ]", "speed unchanged": "kiirus muutmata", - "Quota window changed. Session settings unchanged.": "Kvoodiperiood muutus. Seansiseaded jäid muutmata.", "Session controls unavailable.": "Seansijuhtimine pole saadaval.", - "Review %d session(s). Repeat the same action to confirm; Esc cancels.": "Vaata üle %d seanssi. Kinnitamiseks korda sama toimingut; Esc tühistab.", - "Review sessions individually with G; this pane cannot show all.": "Vaata seansid ükshaaval üle klahviga G; kõik ei mahu paneelile.", "Session skipped for this threshold.": "Seanss jäeti selle lävendi puhul vahele.", - "QUOTA PROFILE": "KVOODIPROFIIL", "Checking / updating…": "Kontrollimine / uuendamine…", - "Review session settings.": "Vaata seansiseaded üle.", - "Approved profile: %s": "Kinnitatud profiil: %s", - "Waiting for threshold / new sessions.": "Lävendi / uute seansside ootel.", "Changes remain after Codexometer closes.": "Muudatused säilivad pärast Codexometeri sulgemist.", "unset": "määramata", - "Gate %d%% — Session %d/%d: %s": "Lävend %d%% — Seanss %d/%d: %s", - "[G: REVIEW ONE]": "[G: VAATA ÜHT]", - "[A: REVIEW ALL]": "[A: VAATA KÕIKI]", - "[N: NEXT]": "[N: JÄRGMINE]", - "[D: SKIP]": "[D: JÄTA VAHELE]", - "[G: CONFIRM ONE]": "[G: KINNITA ÜKS]", - "[A: CONFIRM ALL]": "[A: KINNITA KÕIK]", - "%d session(s) already at target; no approval needed.": "%d seansil on juba sihtseaded; kinnitust pole vaja.", "Session check failed: %s": "Seansside kontroll nurjus: %s", "Verified %d of %d session updates. No automatic retries.": "Kontrollitud %d seansiuuendust %d-st. Automaatseid korduskatseid ei tehta.", "Check Codex: %s": "Kontrolli Codexit: %s", diff --git a/internal/i18n/locales/fi.json b/internal/i18n/locales/fi.json index cc76e90..e9da951 100644 --- a/internal/i18n/locales/fi.json +++ b/internal/i18n/locales/fi.json @@ -1,25 +1,16 @@ { + "QUOTA THRESHOLD": "KIINTIÖKYNNYS", + "Quota threshold: %d%%": "Kiintiökynnys: %d%%", + "Refresh quota before approving.": "Päivitä kiintiö ennen hyväksymistä.", + "[ (1) APPLY PROFILE ]": "[ (1) KÄYTÄ PROFIILIA ]", + "[ (2) SKIP ]": "[ (2) OHITA ]", + "[ (C) CONFIRM PROFILE ]": "[ (C) VAHVISTA PROFIILI ]", "speed unchanged": "nopeus ennallaan", - "Quota window changed. Session settings unchanged.": "Kiintiöjakso vaihtui. Istuntoasetukset säilyvät ennallaan.", "Session controls unavailable.": "Istunnon hallinta ei ole käytettävissä.", - "Review %d session(s). Repeat the same action to confirm; Esc cancels.": "Tarkista %d istuntoa. Vahvista toistamalla sama toiminto; Esc peruuttaa.", - "Review sessions individually with G; this pane cannot show all.": "Tarkista istunnot yksitellen G-näppäimellä; kaikki eivät mahdu paneeliin.", "Session skipped for this threshold.": "Istunto ohitettiin tällä kynnysarvolla.", - "QUOTA PROFILE": "KIINTIÖPROFIILI", "Checking / updating…": "Tarkistetaan / päivitetään…", - "Review session settings.": "Tarkista istuntoasetukset.", - "Approved profile: %s": "Hyväksytty profiili: %s", - "Waiting for threshold / new sessions.": "Odotetaan kynnysarvoa / uusia istuntoja.", "Changes remain after Codexometer closes.": "Muutokset säilyvät Codexometerin sulkemisen jälkeen.", "unset": "ei asetettu", - "Gate %d%% — Session %d/%d: %s": "Kynnys %d%% — Istunto %d/%d: %s", - "[G: REVIEW ONE]": "[G: TARKISTA YKSI]", - "[A: REVIEW ALL]": "[A: TARKISTA KAIKKI]", - "[N: NEXT]": "[N: SEURAAVA]", - "[D: SKIP]": "[D: OHITA]", - "[G: CONFIRM ONE]": "[G: VAHVISTA YKSI]", - "[A: CONFIRM ALL]": "[A: VAHVISTA KAIKKI]", - "%d session(s) already at target; no approval needed.": "%d istuntoa vastaa jo tavoiteasetuksia; hyväksyntää ei tarvita.", "Session check failed: %s": "Istuntojen tarkistus epäonnistui: %s", "Verified %d of %d session updates. No automatic retries.": "Varmennettu %d / %d istuntopäivitystä. Ei automaattisia uudelleenyrityksiä.", "Check Codex: %s": "Tarkista Codex: %s", diff --git a/internal/i18n/locales/fr.json b/internal/i18n/locales/fr.json index 286fe7d..addba67 100644 --- a/internal/i18n/locales/fr.json +++ b/internal/i18n/locales/fr.json @@ -1,25 +1,16 @@ { + "QUOTA THRESHOLD": "SEUIL DE QUOTA", + "Quota threshold: %d%%": "Seuil de quota : %d%%", + "Refresh quota before approving.": "Actualisez le quota avant de confirmer.", + "[ (1) APPLY PROFILE ]": "[ (1) APPLIQUER LE PROFIL ]", + "[ (2) SKIP ]": "[ (2) IGNORER ]", + "[ (C) CONFIRM PROFILE ]": "[ (C) CONFIRMER LE PROFIL ]", "speed unchanged": "vitesse inchangée", - "Quota window changed. Session settings unchanged.": "Période de quota modifiée. Paramètres des sessions inchangés.", "Session controls unavailable.": "Contrôle des sessions indisponible.", - "Review %d session(s). Repeat the same action to confirm; Esc cancels.": "Vérifiez %d session(s). Répétez la même action pour confirmer ; Esc annule.", - "Review sessions individually with G; this pane cannot show all.": "Vérifiez les sessions une par une avec G ; ce panneau ne peut pas toutes les afficher.", "Session skipped for this threshold.": "Session ignorée pour ce seuil.", - "QUOTA PROFILE": "PROFIL DE QUOTA", "Checking / updating…": "Vérification / mise à jour…", - "Review session settings.": "Vérifiez les paramètres des sessions.", - "Approved profile: %s": "Profil approuvé : %s", - "Waiting for threshold / new sessions.": "En attente du seuil / de nouvelles sessions.", "Changes remain after Codexometer closes.": "Les modifications restent après la fermeture de Codexometer.", "unset": "non défini", - "Gate %d%% — Session %d/%d: %s": "Seuil %d%% — Session %d/%d : %s", - "[G: REVIEW ONE]": "[G: VÉRIFIER UNE]", - "[A: REVIEW ALL]": "[A: VÉRIFIER TOUTES]", - "[N: NEXT]": "[N: SUIVANTE]", - "[D: SKIP]": "[D: IGNORER]", - "[G: CONFIRM ONE]": "[G: CONFIRMER UNE]", - "[A: CONFIRM ALL]": "[A: CONFIRMER TOUTES]", - "%d session(s) already at target; no approval needed.": "%d session(s) déjà conformes à la cible ; aucune approbation nécessaire.", "Session check failed: %s": "Échec de vérification des sessions : %s", "Verified %d of %d session updates. No automatic retries.": "%d mises à jour de session sur %d vérifiées. Aucun nouvel essai automatique.", "Check Codex: %s": "Vérifiez Codex : %s", diff --git a/internal/i18n/locales/it.json b/internal/i18n/locales/it.json index 92746bd..7d23477 100644 --- a/internal/i18n/locales/it.json +++ b/internal/i18n/locales/it.json @@ -1,25 +1,16 @@ { + "QUOTA THRESHOLD": "SOGLIA QUOTA", + "Quota threshold: %d%%": "Soglia quota: %d%%", + "Refresh quota before approving.": "Aggiorna la quota prima di confermare.", + "[ (1) APPLY PROFILE ]": "[ (1) APPLICA PROFILO ]", + "[ (2) SKIP ]": "[ (2) SALTA ]", + "[ (C) CONFIRM PROFILE ]": "[ (C) CONFERMA PROFILO ]", "speed unchanged": "velocità invariata", - "Quota window changed. Session settings unchanged.": "Periodo della quota cambiato. Impostazioni delle sessioni invariate.", "Session controls unavailable.": "Controlli delle sessioni non disponibili.", - "Review %d session(s). Repeat the same action to confirm; Esc cancels.": "Verifica %d sessione/i. Ripeti la stessa azione per confermare; Esc annulla.", - "Review sessions individually with G; this pane cannot show all.": "Verifica le sessioni singolarmente con G; questo pannello non può mostrarle tutte.", "Session skipped for this threshold.": "Sessione ignorata per questa soglia.", - "QUOTA PROFILE": "PROFILO QUOTA", "Checking / updating…": "Verifica / aggiornamento…", - "Review session settings.": "Verifica le impostazioni delle sessioni.", - "Approved profile: %s": "Profilo approvato: %s", - "Waiting for threshold / new sessions.": "In attesa della soglia / di nuove sessioni.", "Changes remain after Codexometer closes.": "Le modifiche rimangono dopo la chiusura di Codexometer.", "unset": "non impostato", - "Gate %d%% — Session %d/%d: %s": "Soglia %d%% — Sessione %d/%d: %s", - "[G: REVIEW ONE]": "[G: VERIFICA UNA]", - "[A: REVIEW ALL]": "[A: VERIFICA TUTTE]", - "[N: NEXT]": "[N: SUCCESSIVA]", - "[D: SKIP]": "[D: IGNORA]", - "[G: CONFIRM ONE]": "[G: CONFERMA UNA]", - "[A: CONFIRM ALL]": "[A: CONFERMA TUTTE]", - "%d session(s) already at target; no approval needed.": "%d sessione/i già conformi alla destinazione; nessuna approvazione necessaria.", "Session check failed: %s": "Verifica delle sessioni fallita: %s", "Verified %d of %d session updates. No automatic retries.": "Verificati %d aggiornamenti di sessione su %d. Nessun tentativo automatico.", "Check Codex: %s": "Controlla Codex: %s", diff --git a/internal/i18n/locales/ja.json b/internal/i18n/locales/ja.json index a215342..da953e2 100644 --- a/internal/i18n/locales/ja.json +++ b/internal/i18n/locales/ja.json @@ -1,25 +1,16 @@ { + "QUOTA THRESHOLD": "クォータしきい値", + "Quota threshold: %d%%": "クォータしきい値: %d%%", + "Refresh quota before approving.": "承認前にクォータを更新してください。", + "[ (1) APPLY PROFILE ]": "[ (1) プロファイルを適用 ]", + "[ (2) SKIP ]": "[ (2) スキップ ]", + "[ (C) CONFIRM PROFILE ]": "[ (C) プロファイルを確定 ]", "speed unchanged": "速度は変更しません", - "Quota window changed. Session settings unchanged.": "クォータ期間が変わりました。セッション設定は変更されません。", "Session controls unavailable.": "セッション操作を利用できません。", - "Review %d session(s). Repeat the same action to confirm; Esc cancels.": "%d 件のセッションを確認してください。同じ操作を繰り返すと確定し、Esc で取り消します。", - "Review sessions individually with G; this pane cannot show all.": "このパネルにはすべて表示できません。G で個別に確認してください。", "Session skipped for this threshold.": "このしきい値ではセッションをスキップしました。", - "QUOTA PROFILE": "クォータプロファイル", "Checking / updating…": "確認中 / 更新中…", - "Review session settings.": "セッション設定を確認してください。", - "Approved profile: %s": "承認済みプロファイル: %s", - "Waiting for threshold / new sessions.": "しきい値 / 新しいセッションを待機中。", "Changes remain after Codexometer closes.": "Codexometer を閉じても変更は維持されます。", "unset": "未設定", - "Gate %d%% — Session %d/%d: %s": "しきい値 %d%% — セッション %d/%d: %s", - "[G: REVIEW ONE]": "[G: 1件確認]", - "[A: REVIEW ALL]": "[A: すべて確認]", - "[N: NEXT]": "[N: 次へ]", - "[D: SKIP]": "[D: スキップ]", - "[G: CONFIRM ONE]": "[G: 1件確定]", - "[A: CONFIRM ALL]": "[A: すべて確定]", - "%d session(s) already at target; no approval needed.": "%d 件のセッションは既に目標設定です。承認は不要です。", "Session check failed: %s": "セッションの確認に失敗しました: %s", "Verified %d of %d session updates. No automatic retries.": "%d / %d 件のセッション更新を検証しました。自動再試行はしません。", "Check Codex: %s": "Codex を確認してください: %s", diff --git a/internal/i18n/locales/nb.json b/internal/i18n/locales/nb.json index 661fd0d..19a3835 100644 --- a/internal/i18n/locales/nb.json +++ b/internal/i18n/locales/nb.json @@ -1,25 +1,16 @@ { + "QUOTA THRESHOLD": "KVOTETERSKEL", + "Quota threshold: %d%%": "Kvoteterskel: %d%%", + "Refresh quota before approving.": "Oppdater kvoten før godkjenning.", + "[ (1) APPLY PROFILE ]": "[ (1) BRUK PROFIL ]", + "[ (2) SKIP ]": "[ (2) HOPP OVER ]", + "[ (C) CONFIRM PROFILE ]": "[ (C) BEKREFT PROFIL ]", "speed unchanged": "hastighet uendret", - "Quota window changed. Session settings unchanged.": "Kvoteperioden er endret. Øktinnstillingene er uendret.", "Session controls unavailable.": "Øktkontroller er utilgjengelige.", - "Review %d session(s). Repeat the same action to confirm; Esc cancels.": "Se gjennom %d økt(er). Gjenta samme handling for å bekrefte; Esc avbryter.", - "Review sessions individually with G; this pane cannot show all.": "Se gjennom økter enkeltvis med G; panelet kan ikke vise alle.", "Session skipped for this threshold.": "Økten ble hoppet over for denne terskelen.", - "QUOTA PROFILE": "KVOTEPROFIL", "Checking / updating…": "Kontrollerer / oppdaterer…", - "Review session settings.": "Se gjennom øktinnstillingene.", - "Approved profile: %s": "Godkjent profil: %s", - "Waiting for threshold / new sessions.": "Venter på terskel / nye økter.", "Changes remain after Codexometer closes.": "Endringene beholdes når Codexometer lukkes.", "unset": "ikke angitt", - "Gate %d%% — Session %d/%d: %s": "Terskel %d%% — Økt %d/%d: %s", - "[G: REVIEW ONE]": "[G: SE ÉN]", - "[A: REVIEW ALL]": "[A: SE ALLE]", - "[N: NEXT]": "[N: NESTE]", - "[D: SKIP]": "[D: HOPP OVER]", - "[G: CONFIRM ONE]": "[G: BEKREFT ÉN]", - "[A: CONFIRM ALL]": "[A: BEKREFT ALLE]", - "%d session(s) already at target; no approval needed.": "%d økt(er) har allerede målinnstillingene; ingen godkjenning nødvendig.", "Session check failed: %s": "Øktkontrollen mislyktes: %s", "Verified %d of %d session updates. No automatic retries.": "Verifiserte %d av %d øktoppdateringer. Ingen automatiske nye forsøk.", "Check Codex: %s": "Kontroller Codex: %s", diff --git a/internal/i18n/locales/nl.json b/internal/i18n/locales/nl.json index 5802304..10bb13b 100644 --- a/internal/i18n/locales/nl.json +++ b/internal/i18n/locales/nl.json @@ -1,25 +1,16 @@ { + "QUOTA THRESHOLD": "QUOTADREMPEL", + "Quota threshold: %d%%": "Quotadrempel: %d%%", + "Refresh quota before approving.": "Ververs het quota vóór goedkeuring.", + "[ (1) APPLY PROFILE ]": "[ (1) PROFIEL TOEPASSEN ]", + "[ (2) SKIP ]": "[ (2) OVERSLAAN ]", + "[ (C) CONFIRM PROFILE ]": "[ (C) PROFIEL BEVESTIGEN ]", "speed unchanged": "snelheid ongewijzigd", - "Quota window changed. Session settings unchanged.": "Quotavenster gewijzigd. Sessie-instellingen ongewijzigd.", "Session controls unavailable.": "Sessiebeheer niet beschikbaar.", - "Review %d session(s). Repeat the same action to confirm; Esc cancels.": "Controleer %d sessie(s). Herhaal dezelfde actie om te bevestigen; Esc annuleert.", - "Review sessions individually with G; this pane cannot show all.": "Controleer sessies afzonderlijk met G; niet alles past in dit paneel.", "Session skipped for this threshold.": "Sessie overgeslagen voor deze drempel.", - "QUOTA PROFILE": "QUOTAPROFIEL", "Checking / updating…": "Controleren / bijwerken…", - "Review session settings.": "Controleer de sessie-instellingen.", - "Approved profile: %s": "Goedgekeurd profiel: %s", - "Waiting for threshold / new sessions.": "Wachten op drempel / nieuwe sessies.", "Changes remain after Codexometer closes.": "Wijzigingen blijven behouden na het sluiten van Codexometer.", "unset": "niet ingesteld", - "Gate %d%% — Session %d/%d: %s": "Drempel %d%% — Sessie %d/%d: %s", - "[G: REVIEW ONE]": "[G: ÉÉN CONTROLEREN]", - "[A: REVIEW ALL]": "[A: ALLES CONTROLEREN]", - "[N: NEXT]": "[N: VOLGENDE]", - "[D: SKIP]": "[D: OVERSLAAN]", - "[G: CONFIRM ONE]": "[G: ÉÉN BEVESTIGEN]", - "[A: CONFIRM ALL]": "[A: ALLES BEVESTIGEN]", - "%d session(s) already at target; no approval needed.": "%d sessie(s) al op doelinstellingen; geen goedkeuring nodig.", "Session check failed: %s": "Sessiecontrole mislukt: %s", "Verified %d of %d session updates. No automatic retries.": "%d van %d sessie-updates geverifieerd. Geen automatische herpogingen.", "Check Codex: %s": "Controleer Codex: %s", diff --git a/internal/i18n/locales/pt-BR.json b/internal/i18n/locales/pt-BR.json index 10f2132..e7192f9 100644 --- a/internal/i18n/locales/pt-BR.json +++ b/internal/i18n/locales/pt-BR.json @@ -1,25 +1,16 @@ { + "QUOTA THRESHOLD": "LIMIAR DE COTA", + "Quota threshold: %d%%": "Limiar de cota: %d%%", + "Refresh quota before approving.": "Atualize a cota antes de confirmar.", + "[ (1) APPLY PROFILE ]": "[ (1) APLICAR PERFIL ]", + "[ (2) SKIP ]": "[ (2) IGNORAR ]", + "[ (C) CONFIRM PROFILE ]": "[ (C) CONFIRMAR PERFIL ]", "speed unchanged": "velocidade inalterada", - "Quota window changed. Session settings unchanged.": "Período de cota alterado. Configurações das sessões inalteradas.", "Session controls unavailable.": "Controles de sessão indisponíveis.", - "Review %d session(s). Repeat the same action to confirm; Esc cancels.": "Revise %d sessão(ões). Repita a mesma ação para confirmar; Esc cancela.", - "Review sessions individually with G; this pane cannot show all.": "Revise as sessões individualmente com G; este painel não pode mostrar todas.", "Session skipped for this threshold.": "Sessão ignorada para este limite.", - "QUOTA PROFILE": "PERFIL DE COTA", "Checking / updating…": "Verificando / atualizando…", - "Review session settings.": "Revise as configurações das sessões.", - "Approved profile: %s": "Perfil aprovado: %s", - "Waiting for threshold / new sessions.": "Aguardando limite / novas sessões.", "Changes remain after Codexometer closes.": "As alterações permanecem após fechar o Codexometer.", "unset": "não definido", - "Gate %d%% — Session %d/%d: %s": "Limite %d%% — Sessão %d/%d: %s", - "[G: REVIEW ONE]": "[G: REVISAR UMA]", - "[A: REVIEW ALL]": "[A: REVISAR TODAS]", - "[N: NEXT]": "[N: PRÓXIMA]", - "[D: SKIP]": "[D: IGNORAR]", - "[G: CONFIRM ONE]": "[G: CONFIRMAR UMA]", - "[A: CONFIRM ALL]": "[A: CONFIRMAR TODAS]", - "%d session(s) already at target; no approval needed.": "%d sessão(ões) já estão nas configurações desejadas; nenhuma aprovação necessária.", "Session check failed: %s": "Falha na verificação das sessões: %s", "Verified %d of %d session updates. No automatic retries.": "Verificadas %d de %d atualizações de sessão. Sem novas tentativas automáticas.", "Check Codex: %s": "Verifique o Codex: %s", diff --git a/internal/i18n/locales/pt-PT.json b/internal/i18n/locales/pt-PT.json index a88d92f..d020124 100644 --- a/internal/i18n/locales/pt-PT.json +++ b/internal/i18n/locales/pt-PT.json @@ -1,25 +1,16 @@ { + "QUOTA THRESHOLD": "LIMIAR DE QUOTA", + "Quota threshold: %d%%": "Limiar de quota: %d%%", + "Refresh quota before approving.": "Atualize a quota antes de confirmar.", + "[ (1) APPLY PROFILE ]": "[ (1) APLICAR PERFIL ]", + "[ (2) SKIP ]": "[ (2) IGNORAR ]", + "[ (C) CONFIRM PROFILE ]": "[ (C) CONFIRMAR PERFIL ]", "speed unchanged": "velocidade inalterada", - "Quota window changed. Session settings unchanged.": "Período de quota alterado. Definições das sessões inalteradas.", "Session controls unavailable.": "Controlos de sessão indisponíveis.", - "Review %d session(s). Repeat the same action to confirm; Esc cancels.": "Reveja %d sessão(ões). Repita a mesma ação para confirmar; Esc cancela.", - "Review sessions individually with G; this pane cannot show all.": "Reveja as sessões individualmente com G; este painel não consegue mostrar todas.", "Session skipped for this threshold.": "Sessão ignorada para este limiar.", - "QUOTA PROFILE": "PERFIL DE QUOTA", "Checking / updating…": "A verificar / atualizar…", - "Review session settings.": "Reveja as definições das sessões.", - "Approved profile: %s": "Perfil aprovado: %s", - "Waiting for threshold / new sessions.": "A aguardar limiar / novas sessões.", "Changes remain after Codexometer closes.": "As alterações mantêm-se após fechar o Codexometer.", "unset": "não definido", - "Gate %d%% — Session %d/%d: %s": "Limiar %d%% — Sessão %d/%d: %s", - "[G: REVIEW ONE]": "[G: REVER UMA]", - "[A: REVIEW ALL]": "[A: REVER TODAS]", - "[N: NEXT]": "[N: SEGUINTE]", - "[D: SKIP]": "[D: IGNORAR]", - "[G: CONFIRM ONE]": "[G: CONFIRMAR UMA]", - "[A: CONFIRM ALL]": "[A: CONFIRMAR TODAS]", - "%d session(s) already at target; no approval needed.": "%d sessão(ões) já têm as definições pretendidas; não é necessária aprovação.", "Session check failed: %s": "Falha na verificação das sessões: %s", "Verified %d of %d session updates. No automatic retries.": "Verificadas %d de %d atualizações de sessão. Sem novas tentativas automáticas.", "Check Codex: %s": "Verifique o Codex: %s", diff --git a/internal/i18n/locales/ru.json b/internal/i18n/locales/ru.json index 20235d2..aa9db32 100644 --- a/internal/i18n/locales/ru.json +++ b/internal/i18n/locales/ru.json @@ -1,25 +1,16 @@ { + "QUOTA THRESHOLD": "ПОРОГ КВОТЫ", + "Quota threshold: %d%%": "Порог квоты: %d%%", + "Refresh quota before approving.": "Обновите квоту перед подтверждением.", + "[ (1) APPLY PROFILE ]": "[ (1) ПРИМЕНИТЬ ПРОФИЛЬ ]", + "[ (2) SKIP ]": "[ (2) ПРОПУСТИТЬ ]", + "[ (C) CONFIRM PROFILE ]": "[ (C) ПОДТВЕРДИТЬ ПРОФИЛЬ ]", "speed unchanged": "скорость не изменяется", - "Quota window changed. Session settings unchanged.": "Период квоты изменился. Настройки сеансов не изменены.", "Session controls unavailable.": "Управление сеансами недоступно.", - "Review %d session(s). Repeat the same action to confirm; Esc cancels.": "Проверьте сеансы (%d). Повторите то же действие для подтверждения; Esc отменяет.", - "Review sessions individually with G; this pane cannot show all.": "Проверяйте сеансы по одному клавишей G; панель не вмещает все.", "Session skipped for this threshold.": "Сеанс пропущен для этого порога.", - "QUOTA PROFILE": "ПРОФИЛЬ КВОТЫ", "Checking / updating…": "Проверка / обновление…", - "Review session settings.": "Проверьте настройки сеансов.", - "Approved profile: %s": "Одобренный профиль: %s", - "Waiting for threshold / new sessions.": "Ожидание порога / новых сеансов.", "Changes remain after Codexometer closes.": "Изменения сохраняются после закрытия Codexometer.", "unset": "не задано", - "Gate %d%% — Session %d/%d: %s": "Порог %d%% — Сеанс %d/%d: %s", - "[G: REVIEW ONE]": "[G: ПРОВЕРИТЬ ОДИН]", - "[A: REVIEW ALL]": "[A: ПРОВЕРИТЬ ВСЕ]", - "[N: NEXT]": "[N: СЛЕДУЮЩИЙ]", - "[D: SKIP]": "[D: ПРОПУСТИТЬ]", - "[G: CONFIRM ONE]": "[G: ПОДТВЕРДИТЬ ОДИН]", - "[A: CONFIRM ALL]": "[A: ПОДТВЕРДИТЬ ВСЕ]", - "%d session(s) already at target; no approval needed.": "Сеансы (%d) уже имеют целевые настройки; одобрение не требуется.", "Session check failed: %s": "Ошибка проверки сеансов: %s", "Verified %d of %d session updates. No automatic retries.": "Проверено %d из %d обновлений сеансов. Без автоматических повторов.", "Check Codex: %s": "Проверьте Codex: %s", diff --git a/internal/i18n/locales/sv.json b/internal/i18n/locales/sv.json index d52261e..26224c2 100644 --- a/internal/i18n/locales/sv.json +++ b/internal/i18n/locales/sv.json @@ -1,25 +1,16 @@ { + "QUOTA THRESHOLD": "KVOTGRÄNS", + "Quota threshold: %d%%": "Kvotgräns: %d%%", + "Refresh quota before approving.": "Uppdatera kvoten före godkännande.", + "[ (1) APPLY PROFILE ]": "[ (1) TILLÄMPA PROFIL ]", + "[ (2) SKIP ]": "[ (2) HOPPA ÖVER ]", + "[ (C) CONFIRM PROFILE ]": "[ (C) BEKRÄFTA PROFIL ]", "speed unchanged": "hastigheten oförändrad", - "Quota window changed. Session settings unchanged.": "Kvotperioden har ändrats. Sessionsinställningarna är oförändrade.", "Session controls unavailable.": "Sessionskontroller är inte tillgängliga.", - "Review %d session(s). Repeat the same action to confirm; Esc cancels.": "Granska %d session(er). Upprepa samma åtgärd för att bekräfta; Esc avbryter.", - "Review sessions individually with G; this pane cannot show all.": "Granska sessioner en i taget med G; panelen kan inte visa alla.", "Session skipped for this threshold.": "Sessionen hoppades över för denna tröskel.", - "QUOTA PROFILE": "KVOTPROFIL", "Checking / updating…": "Kontrollerar / uppdaterar…", - "Review session settings.": "Granska sessionsinställningarna.", - "Approved profile: %s": "Godkänd profil: %s", - "Waiting for threshold / new sessions.": "Väntar på tröskel / nya sessioner.", "Changes remain after Codexometer closes.": "Ändringarna kvarstår när Codexometer stängs.", "unset": "inte inställt", - "Gate %d%% — Session %d/%d: %s": "Tröskel %d%% — Session %d/%d: %s", - "[G: REVIEW ONE]": "[G: GRANSKA EN]", - "[A: REVIEW ALL]": "[A: GRANSKA ALLA]", - "[N: NEXT]": "[N: NÄSTA]", - "[D: SKIP]": "[D: HOPPA ÖVER]", - "[G: CONFIRM ONE]": "[G: BEKRÄFTA EN]", - "[A: CONFIRM ALL]": "[A: BEKRÄFTA ALLA]", - "%d session(s) already at target; no approval needed.": "%d session(er) har redan målinställningarna; inget godkännande behövs.", "Session check failed: %s": "Sessionskontrollen misslyckades: %s", "Verified %d of %d session updates. No automatic retries.": "Verifierade %d av %d sessionsuppdateringar. Inga automatiska återförsök.", "Check Codex: %s": "Kontrollera Codex: %s", diff --git a/internal/i18n/locales/tr.json b/internal/i18n/locales/tr.json index 8289c28..15b0744 100644 --- a/internal/i18n/locales/tr.json +++ b/internal/i18n/locales/tr.json @@ -1,25 +1,16 @@ { + "QUOTA THRESHOLD": "KOTA EŞİĞİ", + "Quota threshold: %d%%": "Kota eşiği: %d%%", + "Refresh quota before approving.": "Onaylamadan önce kotayı yenileyin.", + "[ (1) APPLY PROFILE ]": "[ (1) PROFİLİ UYGULA ]", + "[ (2) SKIP ]": "[ (2) ATLA ]", + "[ (C) CONFIRM PROFILE ]": "[ (C) PROFİLİ ONAYLA ]", "speed unchanged": "hız değişmedi", - "Quota window changed. Session settings unchanged.": "Kota dönemi değişti. Oturum ayarları değişmedi.", "Session controls unavailable.": "Oturum denetimleri kullanılamıyor.", - "Review %d session(s). Repeat the same action to confirm; Esc cancels.": "%d oturumu inceleyin. Onaylamak için aynı işlemi tekrarlayın; Esc iptal eder.", - "Review sessions individually with G; this pane cannot show all.": "Bu panel tümünü gösteremez; G ile oturumları tek tek inceleyin.", "Session skipped for this threshold.": "Bu eşik için oturum atlandı.", - "QUOTA PROFILE": "KOTA PROFİLİ", "Checking / updating…": "Denetleniyor / güncelleniyor…", - "Review session settings.": "Oturum ayarlarını inceleyin.", - "Approved profile: %s": "Onaylanan profil: %s", - "Waiting for threshold / new sessions.": "Eşik / yeni oturumlar bekleniyor.", "Changes remain after Codexometer closes.": "Codexometer kapandıktan sonra değişiklikler korunur.", "unset": "ayarlanmamış", - "Gate %d%% — Session %d/%d: %s": "Eşik %d%% — Oturum %d/%d: %s", - "[G: REVIEW ONE]": "[G: BİRİNİ İNCELE]", - "[A: REVIEW ALL]": "[A: TÜMÜNÜ İNCELE]", - "[N: NEXT]": "[N: SONRAKİ]", - "[D: SKIP]": "[D: ATLA]", - "[G: CONFIRM ONE]": "[G: BİRİNİ ONAYLA]", - "[A: CONFIRM ALL]": "[A: TÜMÜNÜ ONAYLA]", - "%d session(s) already at target; no approval needed.": "%d oturum zaten hedef ayarlarda; onay gerekmiyor.", "Session check failed: %s": "Oturum denetimi başarısız: %s", "Verified %d of %d session updates. No automatic retries.": "%d / %d oturum güncellemesi doğrulandı. Otomatik yeniden deneme yapılmaz.", "Check Codex: %s": "Codex'i kontrol edin: %s", diff --git a/internal/i18n/locales/zh-Hans.json b/internal/i18n/locales/zh-Hans.json index 8c9e1ec..52e47b7 100644 --- a/internal/i18n/locales/zh-Hans.json +++ b/internal/i18n/locales/zh-Hans.json @@ -1,25 +1,16 @@ { + "QUOTA THRESHOLD": "配额阈值", + "Quota threshold: %d%%": "配额阈值:%d%%", + "Refresh quota before approving.": "请先刷新配额再确认。", + "[ (1) APPLY PROFILE ]": "[ (1) 应用配置 ]", + "[ (2) SKIP ]": "[ (2) 跳过 ]", + "[ (C) CONFIRM PROFILE ]": "[ (C) 确认配置 ]", "speed unchanged": "速度不变", - "Quota window changed. Session settings unchanged.": "配额周期已更改。会话设置保持不变。", "Session controls unavailable.": "会话控制不可用。", - "Review %d session(s). Repeat the same action to confirm; Esc cancels.": "请检查 %d 个会话。重复相同操作以确认;Esc 取消。", - "Review sessions individually with G; this pane cannot show all.": "此面板无法显示全部会话。请按 G 逐个检查。", "Session skipped for this threshold.": "已跳过此阈值下的会话。", - "QUOTA PROFILE": "配额配置", "Checking / updating…": "正在检查 / 更新…", - "Review session settings.": "请检查会话设置。", - "Approved profile: %s": "已批准的配置:%s", - "Waiting for threshold / new sessions.": "等待阈值 / 新会话。", "Changes remain after Codexometer closes.": "关闭 Codexometer 后,更改仍会保留。", "unset": "未设置", - "Gate %d%% — Session %d/%d: %s": "阈值 %d%% — 会话 %d/%d:%s", - "[G: REVIEW ONE]": "[G: 检查一个]", - "[A: REVIEW ALL]": "[A: 检查全部]", - "[N: NEXT]": "[N: 下一个]", - "[D: SKIP]": "[D: 跳过]", - "[G: CONFIRM ONE]": "[G: 确认一个]", - "[A: CONFIRM ALL]": "[A: 确认全部]", - "%d session(s) already at target; no approval needed.": "%d 个会话已达到目标设置;无需批准。", "Session check failed: %s": "会话检查失败:%s", "Verified %d of %d session updates. No automatic retries.": "已验证 %d 个会话更新,共 %d 个。不会自动重试。", "Check Codex: %s": "请检查 Codex:%s", diff --git a/internal/ui/model.go b/internal/ui/model.go index 84a0023..1b8bf2d 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -75,10 +75,8 @@ type Model struct { quotaSteps []codex.QuotaStep quota quotaControl quotaStepPending *codex.QuotaStep - quotaStepActive *codex.QuotaStep quotaStepWindow string quotaStepBusy bool - quotaStepNotice string quotaStepConfirmUntil time.Time fetcher Fetcher usageFetcher TokenUsageFetcher @@ -522,9 +520,9 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { return m, m.evaluateQuotaStep(m.snapshot) } m.quota.sessions = message.sessions - m.quotaStepNotice = i18n.Format("%d session(s) already at target; no approval needed.", message.matched) + m.quota.scanError = "" if message.err != nil { - m.quotaStepNotice = i18n.Format("Session check failed: %s", message.err.Error()) + m.quota.scanError = i18n.Format("Session check failed: %s", message.err.Error()) } return m, nil case quotaStepResult: @@ -542,27 +540,16 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { for _, s := range message.targets { m.quota.handled[s.ID] = message.step.Threshold } - m.quota.sessions = nil // Re-read settings before another threshold can be approved. - m.quotaStepNotice = i18n.Format("Verified %d of %d session updates. No automatic retries.", message.updated, len(message.targets)) - if message.updated > 0 { - step := message.step - m.quotaStepActive = &step - } + m.quota.busySession = "" + notice := i18n.Format("Verified %d of %d session updates. No automatic retries.", message.updated, len(message.targets)) if message.err != nil { - m.quotaStepNotice += " " + i18n.Format("Check Codex: %s", message.err.Error()) + notice += " " + i18n.Format("Check Codex: %s", message.err.Error()) + } + for _, target := range message.targets { + m.setQuotaSessionNotice(target.ID, notice) } return m, nil case tea.KeyPressMsg: - if !message.IsRepeat { - if next, cmd, handled := m.quotaProfileKey(strings.ToLower(message.String())); handled { - return next, cmd - } - } else if m.meterView.isQuota() && len(m.quotaSteps) > 0 { - switch strings.ToLower(message.String()) { - case "g", "a", "d", "n": - return m, nil - } - } if message.IsRepeat && m.meterView == viewMonitor && (strings.EqualFold(message.String(), "c") || len(message.String()) == 1 && message.String()[0] >= '1' && message.String()[0] <= '8') { return m, nil } @@ -806,12 +793,6 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } mouse := message.Mouse() _, clicked := message.(tea.MouseClickMsg) - if clicked && mouse.Button == tea.MouseLeft { - if key := m.quotaActionAt(mouse.X, mouse.Y); key != "" { - next, cmd, _ := m.quotaProfileKey(key) - return next, cmd - } - } m.history.hovered = 0 if m.meterView == viewUsage { if action, ok := m.historyButtonAt(mouse.X, mouse.Y); ok { @@ -1484,7 +1465,6 @@ func (m Model) dashboardLayout() dashboardGeometry { const footerHeight = 2 extraHeight := 0 extraHeight += m.resetNoticeHeight(contentWidth) - extraHeight += m.quotaStepNoticeHeight(contentWidth) if m.err != nil && m.meterView != viewUsage { extraHeight += framedErrorHeight } @@ -2686,6 +2666,7 @@ func (m *Model) syncMonitorSessions(usage codex.LiveUsageSnapshot, observedAt ti } func (m *Model) dismissMonitorSession(id string) { + m.clearQuotaConfirmation() if id == m.monitorContextTarget() { m.monitorContextDetail = "" m.monitorContextExpanded = "" @@ -2863,6 +2844,7 @@ func (m *Model) scrollMonitorRows(rows int) { } func (m *Model) selectMonitorSession(direction int) { + m.clearQuotaConfirmation() visible := make([]string, 0, len(m.monitorSessionData)) selected := -1 for _, session := range m.monitorSessionData { diff --git a/internal/ui/monitor.go b/internal/ui/monitor.go index aa14e86..4d9f420 100644 --- a/internal/ui/monitor.go +++ b/internal/ui/monitor.go @@ -150,6 +150,10 @@ func (m Model) renderMonitorSummary(width, height int, colors palette, navigatio if m.monitorError != "" { status = " // " + terminalLabel(hint) } + if m.monitorError == "" && m.quota.scanError != "" { + status = " // " + terminalLabel(m.quota.scanError) + stateColor = colors.warning + } if len(lines) < height-2 { lines = append(lines, ansi.Truncate(lipgloss.NewStyle().Bold(true).Foreground(stateColor).Render(state)+colors.dimmed().Render(status), innerWidth, "")) } diff --git a/internal/ui/monitor_context.go b/internal/ui/monitor_context.go index b1cb7da..caf85f2 100644 --- a/internal/ui/monitor_context.go +++ b/internal/ui/monitor_context.go @@ -97,6 +97,14 @@ func (m Model) renderMonitorContextRow(width, height int, metrics string, s moni graph := m.renderMonitorGraphWithAction(gw, height, s.samples, i18n.Text("TOKEN BARS"), m.renderMonitorNavigation(gw, s.id, false, colors), colors) return lipgloss.JoinHorizontal(lipgloss.Top, metrics, " ", graph) } + if _, pending := m.sessionProfile(s); pending { + panel := m.renderSessionProfile(cw, height, s, colors) + row := lipgloss.JoinHorizontal(lipgloss.Top, metrics, " ", panel) + if gw > 0 { + row = lipgloss.JoinHorizontal(lipgloss.Top, row, " ", m.renderMonitorGraphWithAction(gw, height, s.samples, i18n.Text("TOKEN BARS"), m.renderMonitorNavigation(gw, s.id, false, colors), colors)) + } + return row + } inner := max(cw-4, 1) text := codex.SanitizeSessionContext(s.preview.Text) if text == "" { @@ -177,6 +185,9 @@ func (m Model) renderMonitorContextDetail(width, height int, colors palette) str if badge := m.renderMonitorSessionBadge(s, max(width-4, 1), colors); badge != "" { title = badge } + if m.hasSessionProfile(s) { + title = i18n.Text("QUOTA THRESHOLD") + } } return frameSizedWithActions(width, rows, title, action, m.renderMonitorCopy(width, m.monitorContextDetail, colors), body, colors.primary, colors) } @@ -195,6 +206,7 @@ func monitorContextBodyLayout(height, controls int) (textRows, gap, controlY int } func (m *Model) toggleMonitorContext() { + m.clearQuotaConfirmation() m.monitorPrompt = monitorPromptState{} m.monitorApprovalConfirm = "" m.monitorApprovalNotice = "" @@ -209,7 +221,7 @@ func (m *Model) toggleMonitorContext() { func (m *Model) openMonitorContext(id string) { for _, s := range m.monitorSessionData { - if s.id == id && (s.preview.Text != "" || s.preview.Kind == codex.SessionContextApproval || id == m.monitorContextExpanded) && m.monitorSessionVisible(s) { + if s.id == id && (s.preview.Text != "" || s.preview.Kind == codex.SessionContextApproval || m.hasSessionProfile(s) || id == m.monitorContextExpanded) && m.monitorSessionVisible(s) { m.setRowContext(id, contextFull) return } @@ -225,6 +237,9 @@ func (m *Model) scrollMonitorContext(delta int) { } func (m Model) updateMonitorContextKey(key string) (Model, tea.Cmd, bool) { + if next, cmd, handled := m.updateProfileKey(key); handled { + return next, cmd, true + } if key == "c" { if len(m.visibleMonitorApprovalButtons()) > 0 { return m.updateMonitorApprovalKey(key) @@ -326,6 +341,11 @@ func (m Model) monitorContextAt(x, y int) string { return "prompt" } } + if s, ok := m.contextDetailSession(); ok { + if hit := profileButtonsAt(m.profileButtons(g.contentWidth, g.meterHeight, s), g.meterHeight, x, y); hit != "" { + return hit + } + } buttons := m.monitorApprovalButtons(g.contentWidth, g.meterHeight) controlRows := 0 if len(buttons) > 0 { @@ -367,6 +387,15 @@ func (m Model) monitorContextAt(x, y int) string { boxWidth = gw } } + if m.rowContextMode(s.id) != contextGraph { + _, cw, _ := monitorSessionColumnWidths(a.width) + if m.rowContextMode(s.id) == contextSplit { + _, cw, _ = m.contextColumns(a.width, s) + } + if hit := profileButtonsAt(m.profileButtons(cw, heights[i], s), heights[i], x-mw-1, y-rowY); hit != "" { + return hit + } + } navigation := m.monitorNavigationButtons(boxWidth, s.id, false) if m.rowContextMode(s.id) == contextWide { navigation = m.expandedContextNavigation(boxWidth, heights[i], s) @@ -396,6 +425,10 @@ func (m Model) updateMonitorContextMouse(msg tea.MouseMsg) (Model, tea.Cmd, bool next, cmd := m.activateMonitorCopy(id) return next, cmd, true } + if strings.HasPrefix(m.monitorContextHover, "profile-") { + id := strings.TrimPrefix(m.monitorContextHover, "profile-apply:") + return m.profileAction(m.monitorContextHover, m.profileArmed(id)) + } if strings.HasPrefix(m.monitorContextHover, "decision:") { return m.monitorApprovalAction(m.monitorContextHover) } diff --git a/internal/ui/monitor_context_modes.go b/internal/ui/monitor_context_modes.go index ccb6e78..efb1317 100644 --- a/internal/ui/monitor_context_modes.go +++ b/internal/ui/monitor_context_modes.go @@ -106,6 +106,7 @@ func (m Model) contextTargetHidden() bool { } func (m *Model) setRowContext(id string, mode int) { + m.clearQuotaConfirmation() m.monitorContextRows = maps.Clone(m.monitorContextRows) if m.monitorContextRows == nil { m.monitorContextRows = make(map[string]rowContextState) @@ -152,7 +153,13 @@ func (m Model) expandedApprovalButtons(width, height int, s monitorSession) []mo } func (m Model) renderExpandedContext(width, height int, s monitorSession, colors palette) string { + if m.hasSessionProfile(s) { + return m.renderSessionProfile(width, height, s, colors) + } lines := expandedContextLines(width, s) + if notice := m.quota.notices[s.id]; notice != "" { + lines = append(lines, strings.Split(ansi.Hardwrap(codex.SanitizeSessionContext(notice), max(width-4, 1), true), "\n")...) + } buttons := m.expandedApprovalButtons(width, height, s) n := 0 controls := "" diff --git a/internal/ui/monitor_copy.go b/internal/ui/monitor_copy.go index da34c6c..f151a4d 100644 --- a/internal/ui/monitor_copy.go +++ b/internal/ui/monitor_copy.go @@ -10,7 +10,7 @@ import ( // Copy the complete observed reply, not its wrapped/truncated presentation. func (m Model) monitorCopyText(id string) string { for _, s := range m.monitorSessionData { - if s.id == id && m.monitorSessionVisible(s) && + if s.id == id && m.monitorSessionVisible(s) && !m.hasSessionProfile(s) && (s.preview.Kind == codex.SessionContextReply || s.preview.Kind == codex.SessionContextActivity) { return codex.SanitizeSessionContext(s.preview.Text) } diff --git a/internal/ui/monitor_detail.go b/internal/ui/monitor_detail.go index 9062727..6d870db 100644 --- a/internal/ui/monitor_detail.go +++ b/internal/ui/monitor_detail.go @@ -37,13 +37,16 @@ func (m Model) contextDetailDocument(width int) []detailLine { if !ok { return []detailLine{{i18n.Text("NO CONTEXT"), "metadata"}} } + if doc := m.profileDocument(s, width); doc != nil { + return doc + } if s.preview.Text == "" { identity := terminalLabel(s.id) + " // " + terminalLabel(s.workingDirectory) var lines []detailLine for _, line := range strings.Split(ansi.Hardwrap(identity, width, true), "\n") { lines = append(lines, detailLine{ansi.Truncate(line, width, ""), "metadata"}) } - return append(lines, detailLine{i18n.Text("NO CONTEXT"), "metadata"}) + return append(lines, detailLine{i18n.Text("NO CONTEXT"), "metadata"}, detailLine{codex.SanitizeSessionContext(m.quota.notices[s.id]), "warning"}) } c := s.preview var lines []detailLine @@ -113,6 +116,9 @@ func (m Model) contextDetailDocument(width int) []detailLine { section(i18n.Text("OFFERED DECISIONS")) appendText(c.ApprovalDecisions, "metadata", "") } + if notice := m.quota.notices[s.id]; notice != "" { + appendText(codex.SanitizeSessionContext(notice), "warning", "") + } return lines } diff --git a/internal/ui/monitor_detail_activity.go b/internal/ui/monitor_detail_activity.go index 4b3b055..a08757d 100644 --- a/internal/ui/monitor_detail_activity.go +++ b/internal/ui/monitor_detail_activity.go @@ -99,6 +99,11 @@ type detailControlLayout struct { // Scroll geometry needs only layout, not a styled rendering of the controls. func (m Model) layoutDetailControls(width, height int) detailControlLayout { + if s, ok := m.contextDetailSession(); ok { + if buttons := m.profileButtons(width, height, s); len(buttons) > 0 { + return detailControlLayout{kind: "profile", rows: buttons[len(buttons)-1].y + 1} + } + } if rows := m.monitorApprovalControlRows(width, height); rows > 0 { return detailControlLayout{kind: "approval", rows: rows} } @@ -117,6 +122,9 @@ func (m Model) layoutDetailControls(width, height int) detailControlLayout { func (layout detailControlLayout) render(m Model, width, height int, colors palette) string { switch layout.kind { + case "profile": + s, _ := m.contextDetailSession() + return m.renderProfileControls(m.profileButtons(width, height, s), colors) case "approval": return m.renderMonitorApprovalControls(width, height, colors) case "prompt": diff --git a/internal/ui/monitor_profile.go b/internal/ui/monitor_profile.go new file mode 100644 index 0000000..0afda6f --- /dev/null +++ b/internal/ui/monitor_profile.go @@ -0,0 +1,243 @@ +package ui + +import ( + "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" +) + +// Profile suggestions are local reviews, not synthetic Codex approval tokens. +// Real approvals/questions always retain their original detail and controls. +func (m Model) sessionProfile(s monitorSession) (codex.QuotaSession, bool) { + if m.monitorPrompt.session == s.id && (m.monitorPrompt.input.Focused() || m.monitorPrompt.busy) { + return codex.QuotaSession{}, false + } + if s.attention == codex.SessionAttentionApproval || s.attention == codex.SessionAttentionInput || + s.preview.Kind == codex.SessionContextApproval || s.preview.Kind == codex.SessionContextQuestion { + return codex.QuotaSession{}, false + } + if m.quotaStepPending == nil { + return codex.QuotaSession{}, false + } + if threshold, handled := m.quota.handled[s.id]; handled && threshold >= m.quotaStepPending.Threshold { + return codex.QuotaSession{}, false + } + for _, candidate := range m.quota.sessions { + if candidate.ID == s.id { + return candidate, true + } + } + return codex.QuotaSession{}, false +} + +func (m Model) hasSessionProfile(s monitorSession) bool { _, ok := m.sessionProfile(s); return ok } + +func (m Model) profileDocument(s monitorSession, width int) []detailLine { + current, ok := m.sessionProfile(s) + if !ok { + return nil + } + speed := i18n.Text("unset") + if current.Tier != nil { + speed = *current.Tier + } + text := terminalLabel(s.id) + " // " + terminalLabel(s.workingDirectory) + "\n\n" + + i18n.Format("Quota threshold: %d%%", m.quotaStepPending.Threshold) + "\n" + + current.Model + " / " + current.Effort + " / " + speed + "\n→ " + quotaStepProfile(*m.quotaStepPending) + "\n\n" + + i18n.Text("Changes remain after Codexometer closes.") + if m.quota.busySession == s.id { + text += "\n" + i18n.Text("Checking / updating…") + } else if !m.quotaFresh() { + text += "\n" + i18n.Text("Refresh quota before approving.") + } + var lines []detailLine + for _, line := range strings.Split(ansi.Hardwrap(codex.SanitizeSessionContext(text), max(width, 1), true), "\n") { + lines = append(lines, detailLine{ansi.Truncate(line, max(width, 1), ""), "warning"}) + } + return lines +} + +func (m Model) profileArmed(id string) bool { + return len(m.quota.confirm) == 1 && m.quota.confirm[0].ID == id && time.Now().Before(m.quotaStepConfirmUntil) +} + +// The complete settings review must fit above the buttons, even in full detail. +// Both hit testing and keyboard dispatch use these same visible controls. +func (m Model) profileButtons(width, height int, s monitorSession) []monitorApprovalButton { + if m.quotaStepBusy || !m.quotaFresh() || m.monitorPrompt.input.Focused() || + m.monitorState != monitorRunning || m.monitorError != "" { + return nil + } + if _, ok := m.sessionProfile(s); !ok { + return nil + } + labels := []string{i18n.Text("[ (1) APPLY PROFILE ]"), i18n.Text("[ (2) SKIP ]")} + if m.profileArmed(s.id) { + labels[0] = i18n.Text("[ (C) CONFIRM PROFILE ]") + } + actions := []string{"profile-apply:" + s.id, "profile-skip:" + s.id} + x, y := 0, 0 + var buttons []monitorApprovalButton + for i, label := range labels { + slot := lipgloss.Width(label) + if i == 0 { + slot = max(slot, lipgloss.Width(i18n.Text("[ (1) APPLY PROFILE ]")), lipgloss.Width(i18n.Text("[ (C) CONFIRM PROFILE ]"))) + } + if slot > width-4 { + return nil + } + if x+slot > width-4 { + x, y = 0, y+1 + } + buttons = append(buttons, monitorApprovalButton{action: actions[i], label: label, x: x, y: y, slot: slot}) + x += slot + 2 + } + rows, _, _ := monitorContextBodyLayout(height, y+1) + if len(m.profileDocument(s, max(width-4, 1))) > rows { + return nil + } + return buttons +} + +func (m Model) renderProfileControls(buttons []monitorApprovalButton, colors palette) string { + if len(buttons) == 0 { + return "" + } + rows := make([]string, buttons[len(buttons)-1].y+1) + for _, b := range buttons { + rows[b.y] += strings.Repeat(" ", max(b.x-lipgloss.Width(rows[b.y]), 0)) + m.renderContextAction(b.action, b.label, colors) + } + return strings.Join(rows, "\n") +} + +func (m Model) renderSessionProfile(width, height int, s monitorSession, colors palette) string { + doc := m.profileDocument(s, max(width-4, 1)) + buttons := m.profileButtons(width, height, s) + n := 0 + if len(buttons) > 0 { + n = buttons[len(buttons)-1].y + 1 + } + rows, gap, _ := monitorContextBodyLayout(height, n) + var body []string + for _, line := range doc[:min(len(doc), rows)] { + body = append(body, line.render(colors)) + } + if len(doc) > rows && rows > 0 { + body[rows-1] = colors.label().Render(ansi.Truncate(i18n.Text("QUOTA THRESHOLD")+" →", max(width-4, 1), "")) + } + if n > 0 { + for len(body) < rows+gap { + body = append(body, "") + } + body = append(body, m.renderProfileControls(buttons, colors)) + } + navigation := m.renderMonitorNavigation(width, s.id, false, colors) + if m.rowContextMode(s.id) == contextSplit { + g := m.dashboardLayout() + _, _, gw := m.contextColumns(g.contentWidth, s) + if gw > 0 { + navigation = "" + } + } + return frameSizedWithActions(width, max(height-2, 1), i18n.Text("QUOTA THRESHOLD"), navigation, "", strings.Join(body, "\n"), colors.warning, colors) +} + +func profileButtonsAt(buttons []monitorApprovalButton, height, x, y int) string { + if len(buttons) == 0 { + return "" + } + _, _, cy := monitorContextBodyLayout(height, buttons[len(buttons)-1].y+1) + for _, b := range buttons { + if y == cy+b.y && x >= 2+b.x && x < 2+b.x+lipgloss.Width(b.label) { + return b.action + } + } + return "" +} + +func (m Model) visibleProfileButtons() []monitorApprovalButton { + if m.meterView != viewMonitor || m.monitorPrompt.input.Focused() { + return nil + } + id := m.monitorSelectedID + g := m.monitorDashboardLayout() + if m.monitorContextDetail != "" { + if s, ok := m.contextDetailSession(); ok { + return m.profileButtons(g.contentWidth, g.meterHeight, s) + } + return nil + } + a := m.monitorArea(g.contentWidth, g.meterHeight) + sessions, heights, _ := m.monitorSessionPage(a.graphHeight) + for i, s := range sessions { + if s.id != id || m.rowContextMode(id) == contextGraph { + continue + } + _, cw, _ := monitorSessionColumnWidths(a.width) + if m.rowContextMode(id) == contextSplit { + _, cw, _ = m.contextColumns(a.width, s) + } + return m.profileButtons(cw, heights[i], s) + } + return nil +} + +func (m Model) updateProfileKey(key string) (Model, tea.Cmd, bool) { + if key == "esc" && len(m.quota.confirm) > 0 { + m.clearQuotaConfirmation() + return m, nil, true + } + buttons := m.visibleProfileButtons() + if len(buttons) == 0 { + return m, nil, false + } + switch key { + case "1": + return m.profileAction(buttons[0].action, false) + case "c": + id := strings.TrimPrefix(buttons[0].action, "profile-apply:") + if m.profileArmed(id) { + return m.profileAction(buttons[0].action, true) + } + case "2": + return m.profileAction(buttons[1].action, false) + } + return m, nil, false +} + +func (m Model) profileAction(action string, confirm bool) (Model, tea.Cmd, bool) { + id, skip := strings.CutPrefix(action, "profile-skip:") + if !skip { + id = strings.TrimPrefix(action, "profile-apply:") + } + if m.monitorContextDetail != "" && m.monitorContextDetail != id { + return m, nil, true + } + if m.monitorSelectedID != id { + m.clearQuotaConfirmation() + m.monitorSelectedID = id + } + // Recheck the exact rendered target; stale clicks cannot approve another row. + found := false + for _, b := range m.visibleProfileButtons() { + if b.action == action { + found = true + break + } + } + if !found { + m.clearQuotaConfirmation() + return m, nil, true + } + if skip { + m.declineQuotaSession(id) + return m, nil, true + } + next, cmd := m.pressQuotaSession(id, confirm) + return next, cmd, true +} diff --git a/internal/ui/monitor_profile_test.go b/internal/ui/monitor_profile_test.go new file mode 100644 index 0000000..5c81e3c --- /dev/null +++ b/internal/ui/monitor_profile_test.go @@ -0,0 +1,225 @@ +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 profileTestModel(t *testing.T) (Model, *quotaStepTestFetcher) { + m, f := quotaTestModel(t) + m.meterView = viewMonitor + m.monitorState = monitorRunning + for _, s := range f.sessions { + m.monitorSessionData = append(m.monitorSessionData, monitorSession{id: s.ID, displayed: true, active: true, workingDirectory: "/projects/test"}) + } + m.setRowContext("one", contextFull) + return m, f +} + +func TestProfileReviewsAreIndividual(t *testing.T) { + m, f := profileTestModel(t) + pills, _ := m.monitorAttentionButtons(160, 1) + if len(pills) != 2 || !strings.Contains(pills[0].label, i18n.Text("QUOTA THRESHOLD")) { + t.Fatalf("pills %#v", pills) + } + m, cmd, handled := m.updateProfileKey("1") + if !handled || cmd != nil || !m.profileArmed("one") { + t.Fatal("first action did not arm") + } + m, cmd, _ = m.updateProfileKey("c") + if cmd == nil { + t.Fatal("confirmation missing") + } + next, _ := m.Update(cmd()) + m = next.(Model) + if f.updates != 1 || len(f.targets) != 1 || f.targets[0].ID != "one" { + t.Fatalf("wrong target: %#v", f.targets) + } + if got := m.quotaCandidates(); len(got) != 1 || got[0].ID != "two" { + t.Fatalf("other session lost review: %#v", got) + } + if m.quota.notices["one"] == "" || m.quota.notices["two"] != "" { + t.Fatal("feedback leaked to another session") + } + m.setRowContext("two", contextFull) + m, cmd, _ = m.updateProfileKey("2") + if cmd != nil || len(m.quotaCandidates()) != 0 || f.updates != 1 { + t.Fatal("skip wrote settings") + } +} + +func TestProfileNativeAttentionAndHiddenControls(t *testing.T) { + for _, kind := range []string{"approval", "input", "question", "graph", "short", "paused", "error", "stale", "focused"} { + t.Run(kind, func(t *testing.T) { + m, f := profileTestModel(t) + switch kind { + case "approval": + m.monitorSessionData[0].preview.Kind = codex.SessionContextApproval + case "input": + m.monitorSessionData[0].attention = codex.SessionAttentionInput + case "question": + m.monitorSessionData[0].preview.Kind = codex.SessionContextQuestion + case "graph": + m.setRowContext("one", contextGraph) + case "short": + m.height = 14 + case "paused": + m.monitorState = monitorPaused + case "error": + m.monitorError = "disconnected" + case "stale": + m.snapshot.FetchedAt = time.Now().Add(-time.Hour) + case "focused": + m.monitorPrompt.input = newMonitorEditor() + m.monitorPrompt.input.Focus() + } + if len(m.visibleProfileButtons()) != 0 { + t.Fatal("unsafe controls visible") + } + _, cmd, _ := m.updateProfileKey("1") + if cmd != nil || f.updates != 0 { + t.Fatal("unexpected write") + } + }) + } +} + +func TestProfileConfirmationCannotFollowSelection(t *testing.T) { + m, f := profileTestModel(t) + m, _, _ = m.updateProfileKey("1") + m.setRowContext("two", contextFull) + m, cmd, _ := m.updateProfileKey("c") + if cmd != nil || f.updates != 0 || m.profileArmed("one") { + t.Fatal("confirmation survived navigation") + } + m, _, _ = m.updateProfileKey("1") + m.quotaStepConfirmUntil = time.Now().Add(-time.Second) + _, cmd, _ = m.updateProfileKey("c") + if cmd != nil { + t.Fatal("expired confirmation submitted") + } +} + +func TestProfileZeroThresholdAndNoQuotaChrome(t *testing.T) { + m, _ := profileTestModel(t) + m.quotaStepPending.Threshold = 0 + m.quotaSteps[0].Threshold = 0 + if len(m.quotaCandidates()) != 2 { + t.Fatal("zero threshold hid unhandled sessions") + } + m.meterView = viewBars + if strings.Contains(ansi.Strip(m.render()), i18n.Text("QUOTA THRESHOLD")) { + t.Fatal("profile leaked into quota view") + } +} + +func TestProfileThresholdChangesBeforeRescan(t *testing.T) { + m, _ := profileTestModel(t) + m, _, _ = m.updateProfileKey("1") + m.snapshot.RateLimits.Secondary.UsedPercent = 96 + if m.quotaFresh() { + t.Fatal("old threshold remained actionable before inventory refresh") + } + _, cmd, _ := m.updateProfileKey("c") + if cmd != nil { + t.Fatal("applied outdated profile") + } +} + +func TestProfilePillOrderAndNativeRequestDocument(t *testing.T) { + m, _ := profileTestModel(t) + m.monitorSessionData = append(m.monitorSessionData, + monitorSession{id: "approval", displayed: true, attention: codex.SessionAttentionApproval}, + monitorSession{id: "input", displayed: true, attention: codex.SessionAttentionInput}, + monitorSession{id: "complete", displayed: true, attention: codex.SessionAttentionComplete}) + got := m.monitorAttentionSessions() + if len(got) != 5 || got[0].id != "approval" || got[1].id != "input" || got[2].id != "one" || got[4].id != "complete" { + t.Fatalf("order %#v", got) + } + m.monitorSessionData[0].preview = codex.SessionContext{Kind: codex.SessionContextApproval, Text: "Real command approval"} + if m.hasSessionProfile(m.monitorSessionData[0]) { + t.Fatal("quota hides Codex approval") + } + if !strings.Contains(strings.Join(m.contextDetailLines(100), "\n"), "Real command approval") { + t.Fatal("real approval text lost") + } +} + +func TestProfileDoesNotInterruptComposer(t *testing.T) { + m, _ := profileTestModel(t) + m.monitorPrompt.session = "one" + m.monitorPrompt.input = newMonitorEditor() + m.monitorPrompt.input.Focus() + if m.hasSessionProfile(m.monitorSessionData[0]) { + t.Fatal("profile replaced active composer") + } + m.monitorPrompt.input.Blur() + if !m.hasSessionProfile(m.monitorSessionData[0]) { + t.Fatal("profile did not return after editing") + } +} + +// Also run under every supported startup locale: every rendered button cell +// must hit the same session/action after both confirmation and resize. +func TestQuotaLocalisedControls(t *testing.T) { + count := 0 + for _, size := range [][2]int{{40, 18}, {80, 40}, {120, 65}, {200, 100}} { + for _, mode := range []int{contextSplit, contextWide, contextFull} { + for _, armed := range []bool{false, true} { + m, _ := profileTestModel(t) + m.width, m.height = size[0], size[1] + m.setRowContext("one", mode) + if armed { + m, _ = m.pressQuotaSession("one", false) + } + g := m.monitorDashboardLayout() + width, height, x, y := g.contentWidth, g.meterHeight, 2, g.meterY + if mode != contextFull { + a := m.monitorArea(g.contentWidth, g.meterHeight) + sessions, heights, _ := m.monitorSessionPage(a.graphHeight) + if len(sessions) == 0 { + continue + } + mw, cw, _ := monitorSessionColumnWidths(a.width) + if mode == contextSplit { + _, cw, _ = m.contextColumns(a.width, sessions[0]) + } + width, height, x, y = cw, heights[0], 2+mw+1, g.meterY+a.topHeight+a.gap-1 + } + buttons := m.profileButtons(width, height, m.monitorSessionData[0]) + if len(buttons) == 0 { + continue + } + count++ + _, _, cy := monitorContextBodyLayout(height, buttons[len(buttons)-1].y+1) + rendered := strings.Split(ansi.Strip(m.render()), "\n") + for _, b := range buttons { + if !strings.Contains(rendered[y+cy+b.y], b.label) { + t.Fatalf("%s missing rendered %q", i18n.Code(), b.label) + } + for col := 0; col < lipgloss.Width(b.label); col++ { + if hit := m.monitorContextAt(x+2+b.x+col, y+cy+b.y); hit != b.action { + t.Fatalf("%s %v mode %d: %s got %s", i18n.Code(), size, mode, b.action, hit) + } + } + } + // Mouse uses the same two-step confirmation, never the neighbouring row. + b := buttons[0] + next, cmd, _ := m.updateMonitorContextMouse(tea.MouseClickMsg{X: x + 2 + b.x, Y: y + cy + b.y, Button: tea.MouseLeft}) + if armed && cmd == nil || !armed && (cmd != nil || !next.profileArmed("one")) { + t.Fatal("mouse confirmation mismatch") + } + } + } + } + if count == 0 { + t.Fatal("no profile buttons tested") + } +} diff --git a/internal/ui/monitor_prompt.go b/internal/ui/monitor_prompt.go index f22066e..3034f17 100644 --- a/internal/ui/monitor_prompt.go +++ b/internal/ui/monitor_prompt.go @@ -41,6 +41,9 @@ func (m Model) monitorPromptOffer() codex.SessionPromptOffer { if !ok { return codex.SessionPromptOffer{} } + if _, pending := m.sessionProfile(s); pending { + return codex.SessionPromptOffer{} + } if s.preview.Kind == codex.SessionContextApproval { return codex.SessionPromptOffer{} } @@ -65,6 +68,11 @@ func (m Model) monitorPromptOffer() codex.SessionPromptOffer { } func (m Model) monitorPromptRows(width, height int) int { + if s, ok := m.contextDetailSession(); ok { + if _, pending := m.sessionProfile(s); pending { + return 0 + } + } if width < 24 || height < 8 || m.monitorContextTarget() == "" || m.contextTargetHidden() { return 0 } diff --git a/internal/ui/monitor_summary.go b/internal/ui/monitor_summary.go index b46742e..fcba324 100644 --- a/internal/ui/monitor_summary.go +++ b/internal/ui/monitor_summary.go @@ -85,9 +85,20 @@ func (m Model) monitorAttentionSessions() []monitorSession { return nil } var sessions []monitorSession - for _, attention := range []codex.SessionAttention{codex.SessionAttentionApproval, codex.SessionAttentionInput, codex.SessionAttentionComplete} { + for _, priority := range []int{0, 1, 2, 3} { for _, s := range m.monitorSessionData { - if m.monitorSessionVisible(s) && s.attention == attention && !(attention == codex.SessionAttentionComplete && s.working) { + p := -1 + switch { + case s.attention == codex.SessionAttentionApproval: + p = 0 + case s.attention == codex.SessionAttentionInput: + p = 1 + case m.hasSessionProfile(s): + p = 2 + case s.attention == codex.SessionAttentionComplete && !s.working: + p = 3 + } + if m.monitorSessionVisible(s) && p == priority { sessions = append(sessions, s) } } @@ -154,7 +165,10 @@ func (m Model) layoutMonitorAttention(sessions []monitorSession, width, rows, co for _, s := range sessions { id := shortSessionID(s.id) state := monitorAttentionStatus(s.attention) - if compact == 3 && s.attention == codex.SessionAttentionComplete { + if m.hasSessionProfile(s) { + state = i18n.Text("QUOTA THRESHOLD") + } + if compact == 3 && s.attention == codex.SessionAttentionComplete && !m.hasSessionProfile(s) { state = i18n.Text("DONE") } if truncate { @@ -195,7 +209,7 @@ func (m Model) renderMonitorAttention(width, rows int, buttons []monitorNavigati lines := make([]string, rows) completed := make(map[string]bool) for _, s := range m.monitorAttentionSessions() { - if s.attention == codex.SessionAttentionComplete { + if s.attention == codex.SessionAttentionComplete && !m.hasSessionProfile(s) { completed["attention:"+s.id] = true } } diff --git a/internal/ui/quota_reset.go b/internal/ui/quota_reset.go index 98e56f2..c8a2482 100644 --- a/internal/ui/quota_reset.go +++ b/internal/ui/quota_reset.go @@ -172,11 +172,7 @@ func resetExpiryRemaining(expiresAt int64, compact bool) string { // One geometry source for rendering, tab allocation and both click surfaces. func (m Model) resetControlsLayout(width int) resetControls { - c := m.baseResetControlsLayout(width) - if m.quotaStepLabel() != "" { - c.extraRows += len(m.quotaActionRows(width)) - } - return c + return m.baseResetControlsLayout(width) } func (m Model) baseResetControlsLayout(width int) resetControls { @@ -236,7 +232,7 @@ func (m Model) resetWarningAt(x, y int) bool { func (m Model) renderResetControls(width int, tabs string, colors palette) string { c := m.baseResetControlsLayout(width) if c.button == "" { - return m.appendQuotaActions(tabs, width) + return tabs } rows := []string{tabs} for range c.extraRows { @@ -250,7 +246,7 @@ func (m Model) renderResetControls(width int, tabs string, colors palette) strin 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 m.appendQuotaActions(strings.Join(rows, "\n"), width) + return strings.Join(rows, "\n") } func (m Model) resetOwnRow(width int) bool { diff --git a/internal/ui/quota_step_down.go b/internal/ui/quota_step_down.go index 8327166..304cb05 100644 --- a/internal/ui/quota_step_down.go +++ b/internal/ui/quota_step_down.go @@ -1,16 +1,14 @@ package ui import ( - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" "context" "fmt" - "github.com/charmbracelet/x/ansi" - "github.com/merefield/codexometer/internal/codex" - "github.com/merefield/codexometer/internal/i18n" "reflect" - "strings" "time" + + tea "charm.land/bubbletea/v2" + "github.com/merefield/codexometer/internal/codex" + "github.com/merefield/codexometer/internal/i18n" ) type quotaControl struct { @@ -18,11 +16,12 @@ type quotaControl struct { cancel context.CancelFunc sessions []codex.QuotaSession handled map[string]int - selected int + notices map[string]string + busySession string + scanError string confirm []codex.QuotaSession confirmStep codex.QuotaStep confirmWindow string - confirmAll bool } func (m Model) CancelQuotaWork() { @@ -34,7 +33,6 @@ func (m Model) CancelQuotaWork() { type quotaScanResult struct { revision uint64 step codex.QuotaStep - matched int sessions []codex.QuotaSession err error } @@ -47,12 +45,6 @@ type quotaStepResult struct { err error } -func quotaStepThreshold(step *codex.QuotaStep) int { - if step == nil { - return 0 - } - return step.Threshold -} func quotaStepProfile(step codex.QuotaStep) string { result := step.Model + " / " + step.Effort if step.ServiceTier != "" { @@ -87,13 +79,23 @@ func quotaPolicyWindow(s codex.Snapshot) (codex.Meter, string, bool) { } func (m Model) quotaFresh() bool { meter, window, ok := quotaPolicyWindow(m.snapshot) - return ok && window == m.quotaStepWindow && !m.loading && !m.resetBusy && m.err == nil && + if !ok || m.quotaStepPending == nil { + return false + } + var candidate *codex.QuotaStep + for _, step := range m.quotaSteps { + if step.Threshold <= meter.Window.UsedPercent { + copy := step + candidate = © + } + } + if !reflect.DeepEqual(candidate, m.quotaStepPending) { + return false + } + return window == m.quotaStepWindow && !m.loading && !m.resetBusy && m.err == nil && *meter.Window.ResetsAt > time.Now().Unix() && !m.snapshot.FetchedAt.IsZero() && time.Since(m.snapshot.FetchedAt) >= 0 && time.Since(m.snapshot.FetchedAt) <= 2*m.refreshEvery } func (m *Model) clearQuotaConfirmation() { - if len(m.quota.confirm) > 0 { - m.quotaStepNotice = "" - } m.quota.confirm = nil m.quotaStepConfirmUntil = time.Time{} } @@ -115,10 +117,11 @@ func (m *Model) evaluateQuotaStep(snapshot codex.Snapshot) tea.Cmd { m.clearQuotaConfirmation() m.quota.sessions = nil m.quota.handled = nil + m.quota.notices = nil + m.quota.busySession = "" + m.quota.scanError = "" m.quotaStepPending = nil m.quotaStepBusy = false - m.quotaStepActive = nil - m.quotaStepNotice = i18n.Text("Quota window changed. Session settings unchanged.") } m.quotaStepWindow = window var candidate *codex.QuotaStep @@ -132,6 +135,9 @@ func (m *Model) evaluateQuotaStep(snapshot codex.Snapshot) tea.Cmd { m.clearQuotaConfirmation() } m.quotaStepPending = candidate + if candidate == nil { + m.quota.scanError = "" + } if candidate == nil || m.quotaStepBusy { return nil } @@ -153,15 +159,12 @@ func (m *Model) evaluateQuotaStep(snapshot codex.Snapshot) tea.Cmd { } sessions, err := c.QuotaSessions(ctx) var candidates []codex.QuotaSession - matched := 0 for _, session := range sessions { - if session.MatchesQuotaStep(resolved) { - matched++ - } else { + if !session.MatchesQuotaStep(resolved) { candidates = append(candidates, session) } } - return quotaScanResult{revision: revision, step: step, sessions: candidates, matched: matched, err: err} + return quotaScanResult{revision: revision, step: step, sessions: candidates, err: err} } } func (m Model) quotaCandidates() []codex.QuotaSession { @@ -170,37 +173,40 @@ func (m Model) quotaCandidates() []codex.QuotaSession { } var result []codex.QuotaSession for _, s := range m.quota.sessions { - if m.quota.handled[s.ID] < m.quotaStepPending.Threshold { + if threshold, handled := m.quota.handled[s.ID]; !handled || threshold < m.quotaStepPending.Threshold { result = append(result, s) } } return result } -func (m Model) pressQuotaStep() (tea.Model, tea.Cmd) { return m.pressQuotaChoice(false) } -func (m Model) pressQuotaChoice(all bool) (tea.Model, tea.Cmd) { - targets := m.quotaCandidates() - if m.quotaStepBusy || len(targets) == 0 || !m.quotaFresh() { + +// A confirmation binds exactly one reviewed session, settings and quota window. +func (m Model) pressQuotaSession(id string, confirm bool) (Model, tea.Cmd) { + var targets []codex.QuotaSession + for _, s := range m.quotaCandidates() { + if s.ID == id { + targets = []codex.QuotaSession{s} + break + } + } + if m.quotaStepBusy || len(targets) != 1 || !m.quotaFresh() { m.clearQuotaConfirmation() return m, nil } - if !all { - targets = []codex.QuotaSession{targets[m.quota.selected%len(targets)]} - } m.resetConfirmUntil = time.Time{} - if time.Now().After(m.quotaStepConfirmUntil) || all != m.quota.confirmAll || !reflect.DeepEqual(targets, m.quota.confirm) || - m.quota.confirmStep != *m.quotaStepPending || m.quota.confirmWindow != m.quotaStepWindow { - m.quota.confirm = append([]codex.QuotaSession(nil), targets...) + if !confirm { + m.quota.confirm = targets m.quota.confirmStep = *m.quotaStepPending - m.quota.confirmAll = all m.quota.confirmWindow = m.quotaStepWindow m.quotaStepConfirmUntil = time.Now().Add(10 * time.Second) - m.quotaStepNotice = i18n.Format("Review %d session(s). Repeat the same action to confirm; Esc cancels.", len(targets)) - if all && (len(targets) > 8 || len(m.renderQuotaStepNotice(max(m.width-4, 1))) > 3500 || m.quotaStepNoticeHeight(max(m.width-4, 1)) > m.height/2) { - m.clearQuotaConfirmation() - m.quotaStepNotice = i18n.Text("Review sessions individually with G; this pane cannot show all.") - } return m, nil } + if time.Now().After(m.quotaStepConfirmUntil) || !reflect.DeepEqual(targets, m.quota.confirm) || + m.quota.confirmStep != *m.quotaStepPending || m.quota.confirmWindow != m.quotaStepWindow { + m.clearQuotaConfirmation() + return m, nil + } + m.quota.busySession = id step := m.quota.confirmStep window := m.quota.confirmWindow m.clearQuotaConfirmation() @@ -213,158 +219,33 @@ func (m Model) pressQuotaChoice(all bool) (tea.Model, tea.Cmd) { defer cancel() c, ok := m.fetcher.(codex.SessionSettingsClient) if !ok { - return quotaStepResult{revision: revision, err: fmt.Errorf("%s", i18n.Text("Session controls unavailable."))} + return quotaStepResult{revision: revision, window: window, step: step, targets: targets, err: fmt.Errorf("%s", i18n.Text("Session controls unavailable."))} } n, err := c.ApplyQuotaProfile(ctx, targets, step) return quotaStepResult{revision: revision, window: window, step: step, targets: targets, updated: n, err: err} } } -func (m *Model) declineQuotaStep() { +func (m *Model) declineQuotaSession(id string) { if m.quotaStepBusy { return } - candidates := m.quotaCandidates() - if len(candidates) == 0 { - return - } - if m.quota.handled == nil { - m.quota.handled = map[string]int{} - } - m.quota.handled[candidates[m.quota.selected%len(candidates)].ID] = m.quotaStepPending.Threshold - m.clearQuotaConfirmation() - m.quotaStepNotice = i18n.Text("Session skipped for this threshold.") -} -func (m Model) quotaStepLabel() string { - if !m.meterView.isQuota() || len(m.quotaSteps) == 0 { - return "" - } - if m.quotaStepBusy { - return i18n.Text("Checking / updating…") - } - if len(m.quotaCandidates()) > 0 { - return i18n.Text("Review session settings.") - } - if m.quotaStepActive != nil { - return i18n.Format("Approved profile: %s", quotaStepProfile(*m.quotaStepActive)) - } - return i18n.Text("Waiting for threshold / new sessions.") -} -func (m Model) renderQuotaStepNotice(width int) string { - c := paletteFor(m.theme) - body := m.quotaStepNotice - if body == "" { - body = m.quotaStepLabel() - } - body += "\n" + i18n.Text("Changes remain after Codexometer closes.") - candidates := m.quotaCandidates() - if len(candidates) > 0 { - s := candidates[m.quota.selected%len(candidates)] - speed := i18n.Text("unset") - if s.Tier != nil { - speed = *s.Tier + for _, s := range m.quotaCandidates() { + if s.ID != id { + continue } - body += "\n" + i18n.Format("Gate %d%% — Session %d/%d: %s", m.quotaStepPending.Threshold, m.quota.selected%len(candidates)+1, len(candidates), s.ID) + "\n" + s.Model + " / " + s.Effort + " / " + speed + " → " + quotaStepProfile(*m.quotaStepPending) - } - if len(m.quota.confirm) > 1 { - for _, s := range m.quota.confirm { - speed := i18n.Text("unset") - if s.Tier != nil { - speed = *s.Tier - } - body += "\n" + s.ID + ": " + s.Model + " / " + s.Effort + " / " + speed + if m.quota.handled == nil { + m.quota.handled = map[string]int{} } - } - return frame(width, i18n.Text("QUOTA PROFILE"), c.label().Render(ansi.Hardwrap(codex.SanitizeSessionContext(body), max(width-4, 1), true)), c.warning, c) -} -func (m Model) quotaStepNoticeHeight(width int) int { - if !m.meterView.isQuota() || len(m.quotaSteps) == 0 { - return 0 - } - return lipgloss.Height(m.renderQuotaStepNotice(width)) -} -func (m Model) quotaProfileKey(key string) (Model, tea.Cmd, bool) { - if !m.meterView.isQuota() || len(m.quotaSteps) == 0 { - return m, nil, false - } - switch strings.ToLower(key) { - case "g", "a": - next, cmd := m.pressQuotaChoice(key == "a") - return next.(Model), cmd, true - case "n": - m.quota.selected++ + m.quota.handled[id] = m.quotaStepPending.Threshold m.clearQuotaConfirmation() - return m, nil, true - case "d": - m.declineQuotaStep() - return m, nil, true - case "esc": - if len(m.quota.confirm) > 0 { - m.clearQuotaConfirmation() - return m, nil, true - } + m.setQuotaSessionNotice(id, i18n.Text("Session skipped for this threshold.")) + return } - return m, nil, false -} - -type quotaAction struct { - key, label string - x, y int } -func (m Model) quotaActions(width int) []quotaAction { - if m.quotaStepLabel() == "" { - return nil - } - labels := []string{"[G: REVIEW ONE]", "[A: REVIEW ALL]", "[N: NEXT]", "[D: SKIP]"} - if len(m.quota.confirm) > 0 { - if m.quota.confirmAll { - labels[1] = "[A: CONFIRM ALL]" - } else { - labels[0] = "[G: CONFIRM ONE]" - } - } - keys := []string{"g", "a", "n", "d"} - x, y := 0, 0 - var actions []quotaAction - for i, label := range labels { - label = i18n.Text(label) - if width < lipgloss.Width(label) { - label = "[" + strings.ToUpper(keys[i]) + "]" - } - if x > 0 && x+lipgloss.Width(label) > width { - x = 0 - y++ - } - actions = append(actions, quotaAction{keys[i], label, x, y}) - x += lipgloss.Width(label) + 1 - } - return actions -} -func (m Model) quotaActionRows(width int) []string { - actions := m.quotaActions(width) - if len(actions) == 0 { - return nil - } - rows := make([]string, actions[len(actions)-1].y+1) - for _, a := range actions { - rows[a.y] += strings.Repeat(" ", max(a.x-lipgloss.Width(rows[a.y]), 0)) + a.label - } - return rows -} -func (m Model) appendQuotaActions(tabs string, width int) string { - rows := m.quotaActionRows(width) - if len(rows) == 0 { - return tabs - } - return tabs + "\n" + strings.Join(rows, "\n") -} -func (m Model) quotaActionAt(x, y int) string { - g := m.dashboardLayout() - base := m.baseResetControlsLayout(g.contentWidth) - for _, a := range m.quotaActions(g.contentWidth) { - if y == g.tabsY+base.extraRows+1+a.y && x >= 2+a.x && x < 2+a.x+lipgloss.Width(a.label) { - return a.key - } +func (m *Model) setQuotaSessionNotice(id, notice string) { + if m.quota.notices == nil { + m.quota.notices = map[string]string{} } - return "" + m.quota.notices[id] = notice } diff --git a/internal/ui/quota_step_down_test.go b/internal/ui/quota_step_down_test.go index 80a93a4..d40a9ac 100644 --- a/internal/ui/quota_step_down_test.go +++ b/internal/ui/quota_step_down_test.go @@ -6,7 +6,6 @@ import ( "context" "errors" "github.com/merefield/codexometer/internal/codex" - "github.com/merefield/codexometer/internal/i18n" "strings" "testing" "time" @@ -78,9 +77,12 @@ func quotaTestModel(t *testing.T) (Model, *quotaStepTestFetcher) { next, _ := m.Update(cmd()) return next.(Model), f } -func quotaPress(m Model, all bool) (Model, tea.Cmd) { - next, cmd := m.pressQuotaChoice(all) - return next.(Model), cmd +func quotaPress(m Model, _ bool) (Model, tea.Cmd) { + targets := m.quotaCandidates() + if len(targets) == 0 { + return m, nil + } + return m.pressQuotaSession(targets[0].ID, m.profileArmed(targets[0].ID)) } func TestQuotaApprovalIsPerSessionAndNewSessionsNeedApproval(t *testing.T) { m, f := quotaTestModel(t) @@ -143,25 +145,7 @@ func TestQuotaConfirmationRejectsStaleOrChangedSnapshots(t *testing.T) { }) } } -func TestQuotaApproveAllBindsReviewedInventory(t *testing.T) { - m, f := quotaTestModel(t) - m, _ = quotaPress(m, true) - f.sessions = append(f.sessions, codex.QuotaSession{ID: "new"}) - m, cmd := quotaPress(m, true) - if cmd == nil { - t.Fatal("no apply") - } - cmd() - if len(f.targets) != 2 { - t.Fatal("included unreviewed session") - } - m, _ = quotaTestModel(t) - m.height = 10 - m, _ = quotaPress(m, true) - if len(m.quota.confirm) != 0 { - t.Fatal("approved list that cannot fit") - } -} + func TestQuotaWindowChangeLeavesSettingsAndIgnoresLateResults(t *testing.T) { m, f := quotaTestModel(t) m, _ = quotaPress(m, false) @@ -179,23 +163,19 @@ func TestQuotaWindowChangeLeavesSettingsAndIgnoresLateResults(t *testing.T) { } next, _ = m.Update(scan()) m = next.(Model) - if f.updates != 1 || m.quotaStepActive != nil || len(m.quota.handled) != 0 { + if f.updates != 1 || len(m.quota.handled) != 0 { t.Fatal("window state not reset") } } func TestQuotaSkipAndControlsIndependentOfReset(t *testing.T) { m, _ := quotaTestModel(t) - m.declineQuotaStep() + m.declineQuotaSession("one") if got := m.quotaCandidates(); len(got) != 1 || got[0].ID != "two" { t.Fatalf("candidates %#v", got) } for _, width := range []int{24, 60, 100} { - rows := m.quotaActionRows(width) - if len(rows) == 0 || !strings.Contains(strings.Join(rows, ""), "G") { - t.Fatal("missing controls") - } - if m.resetControlsLayout(width).extraRows != m.baseResetControlsLayout(width).extraRows+len(rows) { - t.Fatal("action rows not reserved") + if m.resetControlsLayout(width) != m.baseResetControlsLayout(width) { + t.Fatal("profile changes reset layout") } } } @@ -206,7 +186,7 @@ func TestQuotaControlsKeepResetVisibleAndClickable(t *testing.T) { for _, width := range []int{28, 64, 100} { m.width = width + 4 rendered := m.renderMainTabs(width, paletteFor(m.theme)) - if !strings.Contains(rendered, "RESET") || !strings.Contains(rendered, "REVIEW") { + if !strings.Contains(rendered, "RESET") || strings.Contains(rendered, "REVIEW") { t.Fatalf("missing controls %q", rendered) } if lipgloss.Width(rendered) > width { @@ -217,11 +197,7 @@ func TestQuotaControlsKeepResetVisibleAndClickable(t *testing.T) { if !m.resetAt(2+c.buttonX, g.tabsY+c.buttonY) { t.Fatal("reset click mismatch") } - for _, a := range m.quotaActions(g.contentWidth) { - if got := m.quotaActionAt(2+a.x, g.tabsY+c.extraRows+1+a.y); got != a.key { - t.Fatalf("action click mismatch %s %s", got, a.key) - } - } + } } @@ -271,14 +247,14 @@ func TestQuotaDisarmingClearsReviewNotice(t *testing.T) { t.Run(action, func(t *testing.T) { m, _ := quotaTestModel(t) m, _ = quotaPress(m, false) - if m.quotaStepNotice == "" { - t.Fatal("review notice missing") + if len(m.quota.confirm) != 1 { + t.Fatal("review missing") } switch action { case "escape": - m, _, _ = m.quotaProfileKey("esc") + m, _, _ = m.updateProfileKey("esc") case "next": - m, _, _ = m.quotaProfileKey("n") + m.setRowContext("two", contextWide) case "timeout": m.quotaStepConfirmUntil = time.Now().Add(-time.Second) next, _ := m.Update(secondMsg(time.Now())) @@ -290,73 +266,15 @@ func TestQuotaDisarmingClearsReviewNotice(t *testing.T) { next, _ := m.Update(tea.WindowSizeMsg{Width: 90, Height: 50}) m = next.(Model) } - if len(m.quota.confirm) != 0 || m.quotaStepNotice != "" { - t.Fatalf("stale confirmation: %#v, %q", m.quota.confirm, m.quotaStepNotice) + if len(m.quota.confirm) != 0 { + t.Fatalf("stale confirmation: %#v", m.quota.confirm) } }) } m, _ := quotaTestModel(t) - m.quotaStepNotice = "Result notice" + m.setQuotaSessionNotice("one", "Result notice") m.clearQuotaConfirmation() - if m.quotaStepNotice != "Result notice" { + if m.quota.notices["one"] != "Result notice" { t.Fatal("unrelated result notice was erased") } } - -func TestQuotaOnlyArmedActionSaysConfirm(t *testing.T) { - m, _ := quotaTestModel(t) - for _, all := range []bool{false, true, false} { - var cmd tea.Cmd - m, cmd = quotaPress(m, all) - if cmd != nil { - t.Fatal("switching actions should only arm review") - } - actions := m.quotaActions(200) - wantOne, wantAll := "[G: CONFIRM ONE]", "[A: REVIEW ALL]" - if all { - wantOne, wantAll = "[G: REVIEW ONE]", "[A: CONFIRM ALL]" - } - if actions[0].label != wantOne || actions[1].label != wantAll { - t.Fatalf("misleading labels: %#v", actions) - } - } -} - -func TestQuotaLocalisedControls(t *testing.T) { - m, _ := quotaTestModel(t) - for _, all := range []bool{false, true} { - m.width = 160 - m.height = 100 - m, _ = quotaPress(m, all) - for _, width := range []int{24, 60, 100, 160} { - m.width = width + 4 - rows := m.quotaActionRows(width) - for _, row := range rows { - if lipgloss.Width(row) > width { - t.Fatalf("%s overflow at %d: %s", i18n.Code(), width, row) - } - } - g := m.dashboardLayout() - base := m.baseResetControlsLayout(g.contentWidth) - for _, a := range m.quotaActions(g.contentWidth) { - for col := 0; col < lipgloss.Width(a.label); col++ { - if got := m.quotaActionAt(2+a.x+col, g.tabsY+base.extraRows+1+a.y); got != a.key { - t.Fatalf("%s incorrect click cell for %s", i18n.Code(), a.label) - } - } - } - notice := m.renderQuotaStepNotice(width) - if strings.Contains(notice, "%!") { - t.Fatalf("invalid translated formatting: %s", notice) - } - } - labels := m.quotaActions(400) - wantOne, wantAll := i18n.Text("[G: CONFIRM ONE]"), i18n.Text("[A: REVIEW ALL]") - if all { - wantOne, wantAll = i18n.Text("[G: REVIEW ONE]"), i18n.Text("[A: CONFIRM ALL]") - } - if labels[0].label != wantOne || labels[1].label != wantAll { - t.Fatalf("untranslated or incorrect buttons: %#v", labels) - } - } -} diff --git a/internal/ui/view.go b/internal/ui/view.go index b658f4c..ac7771e 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -51,9 +51,6 @@ func (m Model) render() string { if m.meterView.isQuota() && m.resetNotice != "" { parts = append(parts, m.renderResetNotice(contentWidth)) } - if m.meterView.isQuota() && len(m.quotaSteps) > 0 { - parts = append(parts, m.renderQuotaStepNotice(contentWidth)) - } meters := m.snapshot.Meters() if m.meterView.isQuota() && m.meterView != viewResets { meters = m.quotaMetersWithInsights(contentWidth) diff --git a/intro-post.md b/intro-post.md index 7623a75..4c527a6 100644 --- a/intro-post.md +++ b/intro-post.md @@ -48,6 +48,13 @@ On tall terminals, full detail retains the summary too. As space runs short, the summary disappears before the attention strip so text and action controls keep priority. These shortcuts navigate only; they never approve or send for you. +Optional quota step-down profiles also join the terminal session command centre: +configure `--quota-step-down PERCENT:MODEL:EFFORT[:SPEED]`, then follow a +**QUOTA THRESHOLD** pill to review the proposed settings for that session. +Apply and confirm individually, or skip; Codex's own approval/input requests +take priority. Nothing changes automatically, and approved settings are not +rolled back when Codexometer closes. + Start with the session that needs you. A **WORKING** badge in its telemetry box blinks only its ball, keeping the text steady; completion and attention states take priority. Recent activity alone is not treated as proof of work. Compact and wide detail panels keep content headings such as **LAST REPLY** or **APPROVAL REQUEST**, while full detail puts the session status in its box title without repeating it in the body. Below each session's token count, the latest observed model, reasoning level and Fast setting take priority—for example, **gpt-6-astra medium fast**. These are recorded selections, not proof of the delivered backend model or speed; unavailable fields are omitted. **AVG TOK/MIN** shows average observed usage since measurement began, excluding paused time, rather than instantaneous generation speed. Total and per-session rates refresh together every five seconds, not on mouse movement or keypresses. From f78fd1cd73c421b5a24cbf52d1cb3323ca0c5890 Mon Sep 17 00:00:00 2001 From: merefield Date: Fri, 18 Sep 2026 10:50:36 +0100 Subject: [PATCH 2/9] FIX: harden session profile reviews --- README.md | 8 +- internal/codex/daemon_status_unix.go | 35 ++++++ internal/codex/quota_step.go | 20 +++- internal/codex/quota_step_test.go | 3 +- internal/codex/session_settings_unix.go | 16 ++- internal/codex/session_settings_unix_test.go | 77 ++++++++++-- internal/i18n/locales/da.json | 10 +- internal/i18n/locales/de.json | 10 +- internal/i18n/locales/en-GB.json | 10 +- internal/i18n/locales/es.json | 10 +- internal/i18n/locales/et.json | 10 +- internal/i18n/locales/fi.json | 10 +- internal/i18n/locales/fr.json | 10 +- internal/i18n/locales/it.json | 10 +- internal/i18n/locales/ja.json | 10 +- internal/i18n/locales/nb.json | 10 +- internal/i18n/locales/nl.json | 10 +- internal/i18n/locales/pt-BR.json | 10 +- internal/i18n/locales/pt-PT.json | 10 +- internal/i18n/locales/ru.json | 10 +- internal/i18n/locales/sv.json | 10 +- internal/i18n/locales/tr.json | 10 +- internal/i18n/locales/zh-Hans.json | 10 +- internal/ui/localisation_test.go | 1 + internal/ui/model.go | 17 ++- internal/ui/monitor_approval.go | 4 +- internal/ui/monitor_attention_actions_test.go | 113 ++++++++++++++++++ internal/ui/monitor_context.go | 12 +- internal/ui/monitor_context_modes.go | 7 +- internal/ui/monitor_profile.go | 69 +++++++---- internal/ui/monitor_profile_test.go | 63 +++++++++- internal/ui/monitor_summary.go | 99 +++++++++++---- internal/ui/quota_step_down.go | 8 +- internal/ui/quota_step_down_test.go | 5 +- intro-post.md | 6 +- 35 files changed, 589 insertions(+), 144 deletions(-) create mode 100644 internal/ui/monitor_attention_actions_test.go diff --git a/README.md b/README.md index 85fde05..1aa93bf 100644 --- a/README.md +++ b/README.md @@ -2092,8 +2092,12 @@ Click **APPLY PROFILE** (or press `1` for the selected session), then click to skip that session at the current threshold; `Esc` cancels confirmation. Navigation, resizing and inventory refresh disarm confirmations. Each approval is for exactly one session—there is no bulk approval and newly discovered -sessions need their own review. Codex approval/input requests take priority; -the quota suggestion becomes available once that request is no longer shown. +sessions need their own review. Each pill represents a distinct review: one +session can have both a Codex approval/input pill and a **QUOTA THRESHOLD** pill. +Codex requests appear first; clicking either pill opens that specific review +with only its own controls. Switching reviews cancels pending confirmation. +Completion pills are suppressed while a quota review remains outstanding and +return once it is handled, if the session is still complete. These profile reviews are terminal-only; the browser interface is unchanged. The shared app-server's experimental `thread/settings/update` changes subsequent turns, not a turn already in progress. A queued acknowledgement alone is not diff --git a/internal/codex/daemon_status_unix.go b/internal/codex/daemon_status_unix.go index 54b30ce..91bba67 100644 --- a/internal/codex/daemon_status_unix.go +++ b/internal/codex/daemon_status_unix.go @@ -36,6 +36,8 @@ type daemonStatusProvider struct { lastStatusAt time.Time statusThreads map[string]struct{} statuses map[string]sessionRuntimeStatus + threadSettings map[string]QuotaSession + settingsSignal chan struct{} settingsMu sync.Mutex settingsClosed bool writeMu sync.Mutex @@ -237,6 +239,8 @@ func (p *daemonStatusProvider) ensureConnected(ctx context.Context) error { p.pending = make(map[int64]chan daemonEnvelope) p.subscribed = make(map[string]struct{}) p.reroutedTurns = make(map[daemonTurnKey]string) + p.threadSettings = make(map[string]QuotaSession) + p.settingsSignal = make(chan struct{}) p.mu.Unlock() go p.readLoop(connection) @@ -395,6 +399,32 @@ func (p *daemonStatusProvider) readLoop(connection *websocket.Conn) { func (p *daemonStatusProvider) handleNotification(method string, params json.RawMessage) { switch method { + case "thread/settings/updated": + var notification struct { + ThreadID string `json:"threadId"` + ThreadSettings struct { + Model string `json:"model"` + Effort string `json:"effort"` + ServiceTier *string `json:"serviceTier"` + } `json:"threadSettings"` + } + if json.Unmarshal(params, ¬ification) != nil || notification.ThreadID == "" || + notification.ThreadSettings.Model == "" || notification.ThreadSettings.Effort == "" { + return + } + p.mu.Lock() + if p.threadSettings == nil { + p.threadSettings = make(map[string]QuotaSession) + } + p.threadSettings[notification.ThreadID] = QuotaSession{ + ID: notification.ThreadID, Model: notification.ThreadSettings.Model, + Effort: notification.ThreadSettings.Effort, Tier: notification.ThreadSettings.ServiceTier, + } + if p.settingsSignal != nil { + close(p.settingsSignal) + } + p.settingsSignal = make(chan struct{}) + p.mu.Unlock() case "model/rerouted": var notification struct { ThreadID string `json:"threadId"` @@ -468,6 +498,11 @@ func (p *daemonStatusProvider) disconnect(connection *websocket.Conn) { p.lastStatusAt = time.Time{} p.statusThreads = nil p.statuses = nil + p.threadSettings = nil + if p.settingsSignal != nil { + close(p.settingsSignal) + p.settingsSignal = nil + } p.mu.Unlock() if current != nil { _ = current.Close() diff --git a/internal/codex/quota_step.go b/internal/codex/quota_step.go index 239a186..aa061ee 100644 --- a/internal/codex/quota_step.go +++ b/internal/codex/quota_step.go @@ -3,6 +3,7 @@ package codex import ( "context" "errors" + "strings" ) type QuotaStep struct { @@ -64,12 +65,25 @@ func (s QuotaSession) MatchesQuotaStep(step QuotaStep) bool { if s.Model != step.Model || s.Effort != step.Effort { return false } - switch step.ServiceTier { + switch canonicalQuotaTier(step.ServiceTier) { case "": return true case "default": - return s.Tier == nil + return canonicalQuotaSessionTier(s.Tier) == "default" default: - return s.Tier != nil && *s.Tier == step.ServiceTier + return canonicalQuotaSessionTier(s.Tier) == canonicalQuotaTier(step.ServiceTier) } } + +// App-server represents standard routing both as a missing service tier and as +// the explicit "default" tier, depending on which response supplies it. +func canonicalQuotaSessionTier(tier *string) string { + if tier == nil || canonicalQuotaTier(*tier) == "" { + return "default" + } + return canonicalQuotaTier(*tier) +} + +func canonicalQuotaTier(tier string) string { + return strings.ToLower(strings.TrimSpace(tier)) +} diff --git a/internal/codex/quota_step_test.go b/internal/codex/quota_step_test.go index c8d0b60..fc601e7 100644 --- a/internal/codex/quota_step_test.go +++ b/internal/codex/quota_step_test.go @@ -3,7 +3,7 @@ package codex import "testing" func TestQuotaTargetMatching(t *testing.T) { - priority, flex := "priority", "flex" + priority, flex, standard := "priority", "flex", "default" for _, tc := range []struct { name string session QuotaSession @@ -16,6 +16,7 @@ func TestQuotaTargetMatching(t *testing.T) { {"different speed", QuotaSession{Model: "small", Effort: "medium", Tier: &flex}, QuotaStep{Model: "small", Effort: "medium", ServiceTier: "priority"}, false}, {"omitted speed", QuotaSession{Model: "small", Effort: "medium", Tier: &flex}, QuotaStep{Model: "small", Effort: "medium"}, true}, {"standard", QuotaSession{Model: "small", Effort: "medium"}, QuotaStep{Model: "small", Effort: "medium", ServiceTier: "default"}, true}, + {"explicit standard", QuotaSession{Model: "small", Effort: "medium", Tier: &standard}, QuotaStep{Model: "small", Effort: "medium", ServiceTier: "default"}, true}, {"standard differs", QuotaSession{Model: "small", Effort: "medium", Tier: &priority}, QuotaStep{Model: "small", Effort: "medium", ServiceTier: "default"}, false}, } { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/codex/session_settings_unix.go b/internal/codex/session_settings_unix.go index b3d601a..b3a5161 100644 --- a/internal/codex/session_settings_unix.go +++ b/internal/codex/session_settings_unix.go @@ -12,11 +12,20 @@ import ( "time" ) -func tierEqual(a, b *string) bool { return a == nil && b == nil || a != nil && b != nil && *a == *b } +func tierEqual(a, b *string) bool { + return canonicalQuotaSessionTier(a) == canonicalQuotaSessionTier(b) +} func sameQuotaSettings(a, b QuotaSession) bool { return a.Model == b.Model && a.Effort == b.Effort && tierEqual(a.Tier, b.Tier) } +func (p *daemonStatusProvider) observedQuotaSettings(id string) (QuotaSession, <-chan struct{}, bool) { + p.mu.Lock() + defer p.mu.Unlock() + current, ok := p.threadSettings[id] + return current, p.settingsSignal, ok +} + // Only call for a thread positively observed as loaded. No settings overrides // are supplied to resume; its response exposes configured speed as well. func (p *daemonStatusProvider) readQuotaSession(ctx context.Context, id string) (QuotaSession, error) { @@ -146,6 +155,10 @@ func (p *daemonStatusProvider) writeQuotaSettings(ctx context.Context, expected deadline := time.NewTimer(3 * time.Second) defer deadline.Stop() for { + observed, changed, ok := p.observedQuotaSettings(expected.ID) + if ok && sameQuotaSettings(observed, expected) { + return nil + } loaded, err := p.loadedThreads(ctx) if err != nil { return err @@ -165,6 +178,7 @@ func (p *daemonStatusProvider) writeQuotaSettings(ctx context.Context, expected return ctx.Err() case <-deadline.C: return errors.New("settings queued but application not verified") + case <-changed: case <-time.After(25 * time.Millisecond): } } diff --git a/internal/codex/session_settings_unix_test.go b/internal/codex/session_settings_unix_test.go index 3ef90ea..6eea30c 100644 --- a/internal/codex/session_settings_unix_test.go +++ b/internal/codex/session_settings_unix_test.go @@ -22,6 +22,7 @@ type quotaDaemonFixture struct { writes []map[string]any fail string queued bool + notify bool started chan struct{} release chan struct{} } @@ -86,6 +87,7 @@ func newQuotaDaemon(t *testing.T) (*daemonStatusProvider, *quotaDaemonFixture) { fixture.mu.Lock() result := map[string]any{} failure := false + var settingsNotification map[string]any id, _ := req.Params["threadId"].(string) switch req.Method { case "initialize": @@ -104,21 +106,29 @@ func newQuotaDaemon(t *testing.T) (*daemonStatusProvider, *quotaDaemonFixture) { result = map[string]any{"model": s.Model, "reasoningEffort": s.Effort, "serviceTier": s.Tier, "thread": map[string]any{"id": id}} case "thread/settings/update": fixture.writes = append(fixture.writes, req.Params) - if !fixture.queued { - s := fixture.sessions[id] - if v, ok := req.Params["model"].(string); ok { - s.Model = v - } - if v, ok := req.Params["effort"].(string); ok { - s.Effort = v + desired := fixture.sessions[id] + if v, ok := req.Params["model"].(string); ok { + desired.Model = v + } + if v, ok := req.Params["effort"].(string); ok { + desired.Effort = v + } + if v, ok := req.Params["serviceTier"]; ok { + desired.Tier = nil + if value, ok := v.(string); ok { + desired.Tier = &value } - if v, ok := req.Params["serviceTier"]; ok { - s.Tier = nil - if value, ok := v.(string); ok { - s.Tier = &value - } + } + if !fixture.queued { + fixture.sessions[id] = desired + } + if fixture.notify { + settingsNotification = map[string]any{ + "method": "thread/settings/updated", + "params": map[string]any{"threadId": id, "threadSettings": map[string]any{ + "model": desired.Model, "effort": desired.Effort, "serviceTier": desired.Tier, + }}, } - fixture.sessions[id] = s } if fixture.started != nil { close(fixture.started) @@ -140,6 +150,9 @@ func newQuotaDaemon(t *testing.T) (*daemonStatusProvider, *quotaDaemonFixture) { if conn.WriteJSON(response) != nil { return } + if settingsNotification != nil && conn.WriteJSON(settingsNotification) != nil { + return + } } })} go server.Serve(listener) @@ -244,6 +257,26 @@ func TestQuotaSettingsPreservesUnownedSpeedAndStandardClearsTier(t *testing.T) { t.Fatal("standard must clear tier") } } +func TestQuotaSettingsTreatsExplicitAndImplicitDefaultTierAsEquivalent(t *testing.T) { + p, f := newQuotaDaemon(t) + explicitDefault := "default" + f.mu.Lock() + s := f.sessions["one"] + s.Tier = &explicitDefault + f.sessions["one"] = s + f.mu.Unlock() + + sessions, err := p.QuotaSessions(context.Background()) + if err != nil { + t.Fatal(err) + } + if !sessions[0].MatchesQuotaStep(QuotaStep{Model: sessions[0].Model, Effort: sessions[0].Effort, ServiceTier: "default"}) { + t.Fatal("explicit default tier was offered as a redundant profile change") + } + if !sameQuotaSettings(sessions[0], QuotaSession{Model: sessions[0].Model, Effort: sessions[0].Effort}) { + t.Fatal("explicit and implicit default tiers compare unequal") + } +} func TestQuotaSettingsNoopCloseDoesNotConnect(t *testing.T) { p := &daemonStatusProvider{socketPath: "/missing/no-socket"} p.CloseQuotaProfiles() @@ -305,3 +338,21 @@ func TestQuotaSettingsQueuedAcknowledgementIsNotSuccess(t *testing.T) { t.Fatalf("unexpected rollback: %#v", f.writes) } } + +func TestQuotaSettingsUpdatedNotificationVerifiesQueuedWrite(t *testing.T) { + p, f := newQuotaDaemon(t) + sessions, _ := p.QuotaSessions(context.Background()) + f.mu.Lock() + f.queued = true // thread/resume deliberately remains stale + f.notify = true + f.mu.Unlock() + n, err := p.ApplyQuotaProfile(context.Background(), sessions[:1], QuotaStep{Model: "small", Effort: "medium"}) + if err != nil || n != 1 { + t.Fatalf("authoritative notification was not accepted: %d %v", n, err) + } + f.mu.Lock() + defer f.mu.Unlock() + if f.sessions["one"].Model != "large" { + t.Fatal("fixture no longer proves resume readback stayed stale") + } +} diff --git a/internal/i18n/locales/da.json b/internal/i18n/locales/da.json index 4d77c9d..47e2a2b 100644 --- a/internal/i18n/locales/da.json +++ b/internal/i18n/locales/da.json @@ -1,19 +1,23 @@ { + "WHY THIS CHANGE": "HVORFOR DENNE ÆNDRING", + "CURRENT PROFILE": "AKTUEL PROFIL", + "PROPOSED PROFILE": "FORESLÅET PROFIL", + "PLEASE NOTE": "BEMÆRK", + "MODEL / REASONING LEVEL / SPEED": "MODEL / RÆSONNEMENTSNIVEAU / HASTIGHED", "QUOTA THRESHOLD": "KVOTETÆRSKEL", - "Quota threshold: %d%%": "Kvotetærskel: %d%%", + "Your %d%% quota threshold has been reached. Approve the profile switch below for this session's next turns, or skip it.": "Din angivne kvotetærskel på %d%% er nået. Godkend profilskiftet nedenfor til denne sessions næste ture, eller spring det over.", "Refresh quota before approving.": "Opdater kvoten før godkendelse.", "[ (1) APPLY PROFILE ]": "[ (1) ANVEND PROFIL ]", "[ (2) SKIP ]": "[ (2) SPRING OVER ]", "[ (C) CONFIRM PROFILE ]": "[ (C) BEKRÆFT PROFIL ]", "speed unchanged": "hastighed uændret", "Session controls unavailable.": "Sessionsstyring er ikke tilgængelig.", + "Profile update not yet verified. Codex accepted the request; Codexometer will check again without resending it.": "Profilopdateringen er endnu ikke bekræftet. Codex har accepteret anmodningen; Codexometer kontrollerer igen uden at sende den igen.", "Session skipped for this threshold.": "Sessionen blev sprunget over for denne tærskel.", "Checking / updating…": "Kontrollerer / opdaterer…", "Changes remain after Codexometer closes.": "Ændringerne bevares, når Codexometer lukkes.", "unset": "ikke angivet", "Session check failed: %s": "Sessionskontrol mislykkedes: %s", - "Verified %d of %d session updates. No automatic retries.": "Verificeret %d af %d sessionsopdateringer. Ingen automatiske genforsøg.", - "Check Codex: %s": "Kontrollér Codex: %s", "Expiry order unavailable; backend chooses the credit.": "Udløbsrækkefølgen er ukendt; serveren vælger kreditten.", "FIRST EXPIRATION IN %s // within %d hours": "FØRSTE UDLØB OM %s // inden for %d timer", "Expiry information unavailable.": "Udløbsoplysninger er ikke tilgængelige.", diff --git a/internal/i18n/locales/de.json b/internal/i18n/locales/de.json index e6dd25f..5a33086 100644 --- a/internal/i18n/locales/de.json +++ b/internal/i18n/locales/de.json @@ -1,19 +1,23 @@ { + "WHY THIS CHANGE": "WARUM DIESE ÄNDERUNG", + "CURRENT PROFILE": "AKTUELLES PROFIL", + "PROPOSED PROFILE": "VORGESCHLAGENES PROFIL", + "PLEASE NOTE": "BITTE BEACHTEN", + "MODEL / REASONING LEVEL / SPEED": "MODELL / DENKNIVEAU / GESCHWINDIGKEIT", "QUOTA THRESHOLD": "KONTINGENTSCHWELLE", - "Quota threshold: %d%%": "Kontingentschwelle: %d%%", + "Your %d%% quota threshold has been reached. Approve the profile switch below for this session's next turns, or skip it.": "Deine Kontingentschwelle von %d%% wurde erreicht. Bestätige den unten gezeigten Profilwechsel für die nächsten Gesprächsrunden dieser Sitzung oder überspringe ihn.", "Refresh quota before approving.": "Kontingent vor der Bestätigung aktualisieren.", "[ (1) APPLY PROFILE ]": "[ (1) PROFIL ANWENDEN ]", "[ (2) SKIP ]": "[ (2) ÜBERSPRINGEN ]", "[ (C) CONFIRM PROFILE ]": "[ (C) PROFIL BESTÄTIGEN ]", "speed unchanged": "Geschwindigkeit unverändert", "Session controls unavailable.": "Sitzungssteuerung nicht verfügbar.", + "Profile update not yet verified. Codex accepted the request; Codexometer will check again without resending it.": "Die Profilaktualisierung wurde noch nicht verifiziert. Codex hat die Anfrage angenommen; Codexometer prüft erneut, ohne sie nochmals zu senden.", "Session skipped for this threshold.": "Sitzung für diesen Schwellenwert übersprungen.", "Checking / updating…": "Prüfen / aktualisieren…", "Changes remain after Codexometer closes.": "Änderungen bleiben nach dem Schließen von Codexometer bestehen.", "unset": "nicht festgelegt", "Session check failed: %s": "Sitzungsprüfung fehlgeschlagen: %s", - "Verified %d of %d session updates. No automatic retries.": "%d von %d Sitzungsaktualisierungen verifiziert. Keine automatischen Wiederholungen.", - "Check Codex: %s": "Codex prüfen: %s", "Expiry order unavailable; backend chooses the credit.": "Ablaufreihenfolge unbekannt; das Backend wählt das Guthaben.", "FIRST EXPIRATION IN %s // within %d hours": "ERSTER ABLAUF IN %s // innerhalb von %d Stunden", "Expiry information unavailable.": "Ablaufinformationen nicht verfügbar.", diff --git a/internal/i18n/locales/en-GB.json b/internal/i18n/locales/en-GB.json index e7625d3..6f5be87 100644 --- a/internal/i18n/locales/en-GB.json +++ b/internal/i18n/locales/en-GB.json @@ -1,19 +1,23 @@ { + "WHY THIS CHANGE": "WHY THIS CHANGE", + "CURRENT PROFILE": "CURRENT PROFILE", + "PROPOSED PROFILE": "PROPOSED PROFILE", + "PLEASE NOTE": "PLEASE NOTE", + "MODEL / REASONING LEVEL / SPEED": "MODEL / REASONING LEVEL / SPEED", "QUOTA THRESHOLD": "QUOTA THRESHOLD", - "Quota threshold: %d%%": "Quota threshold: %d%%", + "Your %d%% quota threshold has been reached. Approve the profile switch below for this session's next turns, or skip it.": "Your %d%% quota threshold has been reached. Approve the profile switch below for this session's next turns, or skip it.", "Refresh quota before approving.": "Refresh quota before approving.", "[ (1) APPLY PROFILE ]": "[ (1) APPLY PROFILE ]", "[ (2) SKIP ]": "[ (2) SKIP ]", "[ (C) CONFIRM PROFILE ]": "[ (C) CONFIRM PROFILE ]", "speed unchanged": "speed unchanged", "Session controls unavailable.": "Session controls unavailable.", + "Profile update not yet verified. Codex accepted the request; Codexometer will check again without resending it.": "Profile update not yet verified. Codex accepted the request; Codexometer will check again without resending it.", "Session skipped for this threshold.": "Session skipped for this threshold.", "Checking / updating…": "Checking / updating…", "Changes remain after Codexometer closes.": "Changes remain after Codexometer closes.", "unset": "unset", "Session check failed: %s": "Session check failed: %s", - "Verified %d of %d session updates. No automatic retries.": "Verified %d of %d session updates. No automatic retries.", - "Check Codex: %s": "Check Codex: %s", "Expiry order unavailable; backend chooses the credit.": "Expiry order unavailable; backend chooses the credit.", "FIRST EXPIRATION IN %s // within %d hours": "FIRST EXPIRATION IN %s // within %d hours", "Expiry information unavailable.": "Expiry information unavailable.", diff --git a/internal/i18n/locales/es.json b/internal/i18n/locales/es.json index a78e937..8bf9670 100644 --- a/internal/i18n/locales/es.json +++ b/internal/i18n/locales/es.json @@ -1,19 +1,23 @@ { + "WHY THIS CHANGE": "MOTIVO DEL CAMBIO", + "CURRENT PROFILE": "PERFIL ACTUAL", + "PROPOSED PROFILE": "PERFIL PROPUESTO", + "PLEASE NOTE": "TEN EN CUENTA", + "MODEL / REASONING LEVEL / SPEED": "MODELO / NIVEL DE RAZONAMIENTO / VELOCIDAD", "QUOTA THRESHOLD": "UMBRAL DE CUOTA", - "Quota threshold: %d%%": "Umbral de cuota: %d%%", + "Your %d%% quota threshold has been reached. Approve the profile switch below for this session's next turns, or skip it.": "Se ha alcanzado tu umbral de cuota del %d%%. Aprueba el cambio de perfil que aparece abajo para los próximos turnos de esta sesión, u omítelo.", "Refresh quota before approving.": "Actualiza la cuota antes de confirmar.", "[ (1) APPLY PROFILE ]": "[ (1) APLICAR PERFIL ]", "[ (2) SKIP ]": "[ (2) OMITIR ]", "[ (C) CONFIRM PROFILE ]": "[ (C) CONFIRMAR PERFIL ]", "speed unchanged": "velocidad sin cambios", "Session controls unavailable.": "Controles de sesión no disponibles.", + "Profile update not yet verified. Codex accepted the request; Codexometer will check again without resending it.": "La actualización del perfil aún no se ha verificado. Codex aceptó la solicitud; Codexometer volverá a comprobarla sin reenviarla.", "Session skipped for this threshold.": "Sesión omitida para este umbral.", "Checking / updating…": "Comprobando / actualizando…", "Changes remain after Codexometer closes.": "Los cambios permanecen después de cerrar Codexometer.", "unset": "sin configurar", "Session check failed: %s": "Error al comprobar las sesiones: %s", - "Verified %d of %d session updates. No automatic retries.": "Verificadas %d de %d actualizaciones de sesión. Sin reintentos automáticos.", - "Check Codex: %s": "Comprueba Codex: %s", "Expiry order unavailable; backend chooses the credit.": "Orden de caducidad desconocido; el servidor elige el crédito.", "FIRST EXPIRATION IN %s // within %d hours": "PRIMER VENCIMIENTO EN %s // en %d horas", "Expiry information unavailable.": "Información de caducidad no disponible.", diff --git a/internal/i18n/locales/et.json b/internal/i18n/locales/et.json index 724cc26..f7c8357 100644 --- a/internal/i18n/locales/et.json +++ b/internal/i18n/locales/et.json @@ -1,19 +1,23 @@ { + "WHY THIS CHANGE": "MUUDATUSE PÕHJUS", + "CURRENT PROFILE": "PRAEGUNE PROFIIL", + "PROPOSED PROFILE": "SOOVITATUD PROFIIL", + "PLEASE NOTE": "PANE TÄHELE", + "MODEL / REASONING LEVEL / SPEED": "MUDEL / ARUTLUSTASE / KIIRUS", "QUOTA THRESHOLD": "KVOODILÄVI", - "Quota threshold: %d%%": "Kvoodilävi: %d%%", + "Your %d%% quota threshold has been reached. Approve the profile switch below for this session's next turns, or skip it.": "Sinu määratud kvoodilävi %d%% on saavutatud. Kinnita allolev profiilivahetus selle seansi järgmisteks voorudeks või jäta see vahele.", "Refresh quota before approving.": "Värskenda kvooti enne kinnitamist.", "[ (1) APPLY PROFILE ]": "[ (1) RAKENDA PROFIIL ]", "[ (2) SKIP ]": "[ (2) JÄTA VAHELE ]", "[ (C) CONFIRM PROFILE ]": "[ (C) KINNITA PROFIIL ]", "speed unchanged": "kiirus muutmata", "Session controls unavailable.": "Seansijuhtimine pole saadaval.", + "Profile update not yet verified. Codex accepted the request; Codexometer will check again without resending it.": "Profiiliuuendus pole veel kinnitatud. Codex võttis taotluse vastu; Codexometer kontrollib uuesti ilma seda uuesti saatmata.", "Session skipped for this threshold.": "Seanss jäeti selle lävendi puhul vahele.", "Checking / updating…": "Kontrollimine / uuendamine…", "Changes remain after Codexometer closes.": "Muudatused säilivad pärast Codexometeri sulgemist.", "unset": "määramata", "Session check failed: %s": "Seansside kontroll nurjus: %s", - "Verified %d of %d session updates. No automatic retries.": "Kontrollitud %d seansiuuendust %d-st. Automaatseid korduskatseid ei tehta.", - "Check Codex: %s": "Kontrolli Codexit: %s", "Expiry order unavailable; backend chooses the credit.": "Aegumise järjekord pole teada; krediidi valib server.", "FIRST EXPIRATION IN %s // within %d hours": "ESIMENE AEGUMINE %s PÄRAST // %d tunni jooksul", "Expiry information unavailable.": "Aegumise teave pole saadaval.", diff --git a/internal/i18n/locales/fi.json b/internal/i18n/locales/fi.json index e9da951..a041a12 100644 --- a/internal/i18n/locales/fi.json +++ b/internal/i18n/locales/fi.json @@ -1,19 +1,23 @@ { + "WHY THIS CHANGE": "MUUTOKSEN SYY", + "CURRENT PROFILE": "NYKYINEN PROFIILI", + "PROPOSED PROFILE": "EHDOTETTU PROFIILI", + "PLEASE NOTE": "HUOMAA", + "MODEL / REASONING LEVEL / SPEED": "MALLI / PÄÄTTELYTASO / NOPEUS", "QUOTA THRESHOLD": "KIINTIÖKYNNYS", - "Quota threshold: %d%%": "Kiintiökynnys: %d%%", + "Your %d%% quota threshold has been reached. Approve the profile switch below for this session's next turns, or skip it.": "Asettamasi %d%%:n kiintiökynnys on saavutettu. Hyväksy alla oleva profiilinvaihto tämän istunnon seuraaville vuoroille tai ohita se.", "Refresh quota before approving.": "Päivitä kiintiö ennen hyväksymistä.", "[ (1) APPLY PROFILE ]": "[ (1) KÄYTÄ PROFIILIA ]", "[ (2) SKIP ]": "[ (2) OHITA ]", "[ (C) CONFIRM PROFILE ]": "[ (C) VAHVISTA PROFIILI ]", "speed unchanged": "nopeus ennallaan", "Session controls unavailable.": "Istunnon hallinta ei ole käytettävissä.", + "Profile update not yet verified. Codex accepted the request; Codexometer will check again without resending it.": "Profiilipäivitystä ei ole vielä vahvistettu. Codex hyväksyi pyynnön; Codexometer tarkistaa uudelleen lähettämättä sitä uudestaan.", "Session skipped for this threshold.": "Istunto ohitettiin tällä kynnysarvolla.", "Checking / updating…": "Tarkistetaan / päivitetään…", "Changes remain after Codexometer closes.": "Muutokset säilyvät Codexometerin sulkemisen jälkeen.", "unset": "ei asetettu", "Session check failed: %s": "Istuntojen tarkistus epäonnistui: %s", - "Verified %d of %d session updates. No automatic retries.": "Varmennettu %d / %d istuntopäivitystä. Ei automaattisia uudelleenyrityksiä.", - "Check Codex: %s": "Tarkista Codex: %s", "Expiry order unavailable; backend chooses the credit.": "Vanhenemisjärjestys ei ole tiedossa; palvelin valitsee krediitin.", "FIRST EXPIRATION IN %s // within %d hours": "ENSIMMÄINEN VANHENEMINEN %s KULUTTUA // %d tunnin kuluessa", "Expiry information unavailable.": "Vanhenemistietoja ei ole saatavilla.", diff --git a/internal/i18n/locales/fr.json b/internal/i18n/locales/fr.json index addba67..da9e0f9 100644 --- a/internal/i18n/locales/fr.json +++ b/internal/i18n/locales/fr.json @@ -1,19 +1,23 @@ { + "WHY THIS CHANGE": "POURQUOI CE CHANGEMENT", + "CURRENT PROFILE": "PROFIL ACTUEL", + "PROPOSED PROFILE": "PROFIL PROPOSÉ", + "PLEASE NOTE": "À NOTER", + "MODEL / REASONING LEVEL / SPEED": "MODÈLE / NIVEAU DE RAISONNEMENT / VITESSE", "QUOTA THRESHOLD": "SEUIL DE QUOTA", - "Quota threshold: %d%%": "Seuil de quota : %d%%", + "Your %d%% quota threshold has been reached. Approve the profile switch below for this session's next turns, or skip it.": "Votre seuil de quota de %d%% a été atteint. Approuvez le changement de profil ci-dessous pour les prochains tours de cette session, ou ignorez-le.", "Refresh quota before approving.": "Actualisez le quota avant de confirmer.", "[ (1) APPLY PROFILE ]": "[ (1) APPLIQUER LE PROFIL ]", "[ (2) SKIP ]": "[ (2) IGNORER ]", "[ (C) CONFIRM PROFILE ]": "[ (C) CONFIRMER LE PROFIL ]", "speed unchanged": "vitesse inchangée", "Session controls unavailable.": "Contrôle des sessions indisponible.", + "Profile update not yet verified. Codex accepted the request; Codexometer will check again without resending it.": "La mise à jour du profil n’est pas encore vérifiée. Codex a accepté la demande ; Codexometer vérifiera à nouveau sans la renvoyer.", "Session skipped for this threshold.": "Session ignorée pour ce seuil.", "Checking / updating…": "Vérification / mise à jour…", "Changes remain after Codexometer closes.": "Les modifications restent après la fermeture de Codexometer.", "unset": "non défini", "Session check failed: %s": "Échec de vérification des sessions : %s", - "Verified %d of %d session updates. No automatic retries.": "%d mises à jour de session sur %d vérifiées. Aucun nouvel essai automatique.", - "Check Codex: %s": "Vérifiez Codex : %s", "Expiry order unavailable; backend chooses the credit.": "Ordre d’expiration inconnu ; le serveur choisit le crédit.", "FIRST EXPIRATION IN %s // within %d hours": "PREMIÈRE EXPIRATION DANS %s // sous %d heures", "Expiry information unavailable.": "Informations d’expiration indisponibles.", diff --git a/internal/i18n/locales/it.json b/internal/i18n/locales/it.json index 7d23477..6b67433 100644 --- a/internal/i18n/locales/it.json +++ b/internal/i18n/locales/it.json @@ -1,19 +1,23 @@ { + "WHY THIS CHANGE": "PERCHÉ QUESTO CAMBIAMENTO", + "CURRENT PROFILE": "PROFILO ATTUALE", + "PROPOSED PROFILE": "PROFILO PROPOSTO", + "PLEASE NOTE": "NOTA BENE", + "MODEL / REASONING LEVEL / SPEED": "MODELLO / LIVELLO DI RAGIONAMENTO / VELOCITÀ", "QUOTA THRESHOLD": "SOGLIA QUOTA", - "Quota threshold: %d%%": "Soglia quota: %d%%", + "Your %d%% quota threshold has been reached. Approve the profile switch below for this session's next turns, or skip it.": "È stata raggiunta la soglia di quota del %d%%. Approva il cambio di profilo qui sotto per i prossimi turni di questa sessione, oppure saltalo.", "Refresh quota before approving.": "Aggiorna la quota prima di confermare.", "[ (1) APPLY PROFILE ]": "[ (1) APPLICA PROFILO ]", "[ (2) SKIP ]": "[ (2) SALTA ]", "[ (C) CONFIRM PROFILE ]": "[ (C) CONFERMA PROFILO ]", "speed unchanged": "velocità invariata", "Session controls unavailable.": "Controlli delle sessioni non disponibili.", + "Profile update not yet verified. Codex accepted the request; Codexometer will check again without resending it.": "L’aggiornamento del profilo non è ancora verificato. Codex ha accettato la richiesta; Codexometer controllerà di nuovo senza reinviarla.", "Session skipped for this threshold.": "Sessione ignorata per questa soglia.", "Checking / updating…": "Verifica / aggiornamento…", "Changes remain after Codexometer closes.": "Le modifiche rimangono dopo la chiusura di Codexometer.", "unset": "non impostato", "Session check failed: %s": "Verifica delle sessioni fallita: %s", - "Verified %d of %d session updates. No automatic retries.": "Verificati %d aggiornamenti di sessione su %d. Nessun tentativo automatico.", - "Check Codex: %s": "Controlla Codex: %s", "Expiry order unavailable; backend chooses the credit.": "Ordine di scadenza sconosciuto; il backend sceglie il credito.", "FIRST EXPIRATION IN %s // within %d hours": "PRIMA SCADENZA TRA %s // entro %d ore", "Expiry information unavailable.": "Informazioni sulla scadenza non disponibili.", diff --git a/internal/i18n/locales/ja.json b/internal/i18n/locales/ja.json index da953e2..8bb811f 100644 --- a/internal/i18n/locales/ja.json +++ b/internal/i18n/locales/ja.json @@ -1,19 +1,23 @@ { + "WHY THIS CHANGE": "変更の理由", + "CURRENT PROFILE": "現在のプロファイル", + "PROPOSED PROFILE": "提案するプロファイル", + "PLEASE NOTE": "注意事項", + "MODEL / REASONING LEVEL / SPEED": "モデル / 推論レベル / 速度", "QUOTA THRESHOLD": "クォータしきい値", - "Quota threshold: %d%%": "クォータしきい値: %d%%", + "Your %d%% quota threshold has been reached. Approve the profile switch below for this session's next turns, or skip it.": "設定したクォータしきい値 %d%% に達しました。このセッションの次のターンから使うプロファイルへの変更を以下で承認するか、スキップしてください。", "Refresh quota before approving.": "承認前にクォータを更新してください。", "[ (1) APPLY PROFILE ]": "[ (1) プロファイルを適用 ]", "[ (2) SKIP ]": "[ (2) スキップ ]", "[ (C) CONFIRM PROFILE ]": "[ (C) プロファイルを確定 ]", "speed unchanged": "速度は変更しません", "Session controls unavailable.": "セッション操作を利用できません。", + "Profile update not yet verified. Codex accepted the request; Codexometer will check again without resending it.": "プロファイル更新はまだ確認されていません。Codex はリクエストを受け付けました。Codexometer は再送せずに再確認します。", "Session skipped for this threshold.": "このしきい値ではセッションをスキップしました。", "Checking / updating…": "確認中 / 更新中…", "Changes remain after Codexometer closes.": "Codexometer を閉じても変更は維持されます。", "unset": "未設定", "Session check failed: %s": "セッションの確認に失敗しました: %s", - "Verified %d of %d session updates. No automatic retries.": "%d / %d 件のセッション更新を検証しました。自動再試行はしません。", - "Check Codex: %s": "Codex を確認してください: %s", "Expiry order unavailable; backend chooses the credit.": "有効期限の順序は不明です。バックエンドがクレジットを選択します。", "FIRST EXPIRATION IN %s // within %d hours": "最初の有効期限まで %s // %d 時間以内", "Expiry information unavailable.": "有効期限情報を取得できません。", diff --git a/internal/i18n/locales/nb.json b/internal/i18n/locales/nb.json index 19a3835..2f2330b 100644 --- a/internal/i18n/locales/nb.json +++ b/internal/i18n/locales/nb.json @@ -1,19 +1,23 @@ { + "WHY THIS CHANGE": "HVORFOR DENNE ENDRINGEN", + "CURRENT PROFILE": "GJELDENDE PROFIL", + "PROPOSED PROFILE": "FORESLÅTT PROFIL", + "PLEASE NOTE": "MERK", + "MODEL / REASONING LEVEL / SPEED": "MODELL / RESONNERINGSNIVÅ / HASTIGHET", "QUOTA THRESHOLD": "KVOTETERSKEL", - "Quota threshold: %d%%": "Kvoteterskel: %d%%", + "Your %d%% quota threshold has been reached. Approve the profile switch below for this session's next turns, or skip it.": "Den angitte kvoteterskelen på %d%% er nådd. Godkjenn profilbyttet nedenfor for de neste turene i denne økten, eller hopp over det.", "Refresh quota before approving.": "Oppdater kvoten før godkjenning.", "[ (1) APPLY PROFILE ]": "[ (1) BRUK PROFIL ]", "[ (2) SKIP ]": "[ (2) HOPP OVER ]", "[ (C) CONFIRM PROFILE ]": "[ (C) BEKREFT PROFIL ]", "speed unchanged": "hastighet uendret", "Session controls unavailable.": "Øktkontroller er utilgjengelige.", + "Profile update not yet verified. Codex accepted the request; Codexometer will check again without resending it.": "Profiloppdateringen er ennå ikke bekreftet. Codex har godtatt forespørselen; Codexometer kontrollerer på nytt uten å sende den igjen.", "Session skipped for this threshold.": "Økten ble hoppet over for denne terskelen.", "Checking / updating…": "Kontrollerer / oppdaterer…", "Changes remain after Codexometer closes.": "Endringene beholdes når Codexometer lukkes.", "unset": "ikke angitt", "Session check failed: %s": "Øktkontrollen mislyktes: %s", - "Verified %d of %d session updates. No automatic retries.": "Verifiserte %d av %d øktoppdateringer. Ingen automatiske nye forsøk.", - "Check Codex: %s": "Kontroller Codex: %s", "Expiry order unavailable; backend chooses the credit.": "Utløpsrekkefølgen er ukjent; serveren velger kreditten.", "FIRST EXPIRATION IN %s // within %d hours": "FØRSTE UTLØP OM %s // innen %d timer", "Expiry information unavailable.": "Utløpsinformasjon er utilgjengelig.", diff --git a/internal/i18n/locales/nl.json b/internal/i18n/locales/nl.json index 10bb13b..b989ddb 100644 --- a/internal/i18n/locales/nl.json +++ b/internal/i18n/locales/nl.json @@ -1,19 +1,23 @@ { + "WHY THIS CHANGE": "WAAROM DEZE WIJZIGING", + "CURRENT PROFILE": "HUIDIG PROFIEL", + "PROPOSED PROFILE": "VOORGESTELD PROFIEL", + "PLEASE NOTE": "LET OP", + "MODEL / REASONING LEVEL / SPEED": "MODEL / REDENEERNIVEAU / SNELHEID", "QUOTA THRESHOLD": "QUOTADREMPEL", - "Quota threshold: %d%%": "Quotadrempel: %d%%", + "Your %d%% quota threshold has been reached. Approve the profile switch below for this session's next turns, or skip it.": "Je ingestelde quotadrempel van %d%% is bereikt. Keur de onderstaande profielwijziging goed voor de volgende beurten van deze sessie, of sla deze over.", "Refresh quota before approving.": "Ververs het quota vóór goedkeuring.", "[ (1) APPLY PROFILE ]": "[ (1) PROFIEL TOEPASSEN ]", "[ (2) SKIP ]": "[ (2) OVERSLAAN ]", "[ (C) CONFIRM PROFILE ]": "[ (C) PROFIEL BEVESTIGEN ]", "speed unchanged": "snelheid ongewijzigd", "Session controls unavailable.": "Sessiebeheer niet beschikbaar.", + "Profile update not yet verified. Codex accepted the request; Codexometer will check again without resending it.": "De profielupdate is nog niet geverifieerd. Codex heeft het verzoek geaccepteerd; Codexometer controleert opnieuw zonder het opnieuw te verzenden.", "Session skipped for this threshold.": "Sessie overgeslagen voor deze drempel.", "Checking / updating…": "Controleren / bijwerken…", "Changes remain after Codexometer closes.": "Wijzigingen blijven behouden na het sluiten van Codexometer.", "unset": "niet ingesteld", "Session check failed: %s": "Sessiecontrole mislukt: %s", - "Verified %d of %d session updates. No automatic retries.": "%d van %d sessie-updates geverifieerd. Geen automatische herpogingen.", - "Check Codex: %s": "Controleer Codex: %s", "Expiry order unavailable; backend chooses the credit.": "Vervolgorde onbekend; de backend kiest het tegoed.", "FIRST EXPIRATION IN %s // within %d hours": "EERSTE VERVAL OVER %s // binnen %d uur", "Expiry information unavailable.": "Vervalinformatie niet beschikbaar.", diff --git a/internal/i18n/locales/pt-BR.json b/internal/i18n/locales/pt-BR.json index e7192f9..0fd5a06 100644 --- a/internal/i18n/locales/pt-BR.json +++ b/internal/i18n/locales/pt-BR.json @@ -1,19 +1,23 @@ { + "WHY THIS CHANGE": "MOTIVO DA ALTERAÇÃO", + "CURRENT PROFILE": "PERFIL ATUAL", + "PROPOSED PROFILE": "PERFIL PROPOSTO", + "PLEASE NOTE": "ATENÇÃO", + "MODEL / REASONING LEVEL / SPEED": "MODELO / NÍVEL DE RACIOCÍNIO / VELOCIDADE", "QUOTA THRESHOLD": "LIMIAR DE COTA", - "Quota threshold: %d%%": "Limiar de cota: %d%%", + "Your %d%% quota threshold has been reached. Approve the profile switch below for this session's next turns, or skip it.": "O limite de cota de %d%% que você configurou foi atingido. Aprove a troca de perfil abaixo para os próximos turnos desta sessão ou ignore-a.", "Refresh quota before approving.": "Atualize a cota antes de confirmar.", "[ (1) APPLY PROFILE ]": "[ (1) APLICAR PERFIL ]", "[ (2) SKIP ]": "[ (2) IGNORAR ]", "[ (C) CONFIRM PROFILE ]": "[ (C) CONFIRMAR PERFIL ]", "speed unchanged": "velocidade inalterada", "Session controls unavailable.": "Controles de sessão indisponíveis.", + "Profile update not yet verified. Codex accepted the request; Codexometer will check again without resending it.": "A atualização do perfil ainda não foi verificada. O Codex aceitou a solicitação; o Codexometer verificará novamente sem reenviá-la.", "Session skipped for this threshold.": "Sessão ignorada para este limite.", "Checking / updating…": "Verificando / atualizando…", "Changes remain after Codexometer closes.": "As alterações permanecem após fechar o Codexometer.", "unset": "não definido", "Session check failed: %s": "Falha na verificação das sessões: %s", - "Verified %d of %d session updates. No automatic retries.": "Verificadas %d de %d atualizações de sessão. Sem novas tentativas automáticas.", - "Check Codex: %s": "Verifique o Codex: %s", "Expiry order unavailable; backend chooses the credit.": "Ordem de vencimento desconhecida; o servidor escolhe o crédito.", "FIRST EXPIRATION IN %s // within %d hours": "PRIMEIRO VENCIMENTO EM %s // em %d horas", "Expiry information unavailable.": "Informações de validade indisponíveis.", diff --git a/internal/i18n/locales/pt-PT.json b/internal/i18n/locales/pt-PT.json index d020124..9c4bcf9 100644 --- a/internal/i18n/locales/pt-PT.json +++ b/internal/i18n/locales/pt-PT.json @@ -1,19 +1,23 @@ { + "WHY THIS CHANGE": "MOTIVO DA ALTERAÇÃO", + "CURRENT PROFILE": "PERFIL ATUAL", + "PROPOSED PROFILE": "PERFIL PROPOSTO", + "PLEASE NOTE": "ATENÇÃO", + "MODEL / REASONING LEVEL / SPEED": "MODELO / NÍVEL DE RACIOCÍNIO / VELOCIDADE", "QUOTA THRESHOLD": "LIMIAR DE QUOTA", - "Quota threshold: %d%%": "Limiar de quota: %d%%", + "Your %d%% quota threshold has been reached. Approve the profile switch below for this session's next turns, or skip it.": "O limiar de quota de %d%% que definiu foi atingido. Aprove a mudança de perfil abaixo para os próximos turnos desta sessão ou ignore-a.", "Refresh quota before approving.": "Atualize a quota antes de confirmar.", "[ (1) APPLY PROFILE ]": "[ (1) APLICAR PERFIL ]", "[ (2) SKIP ]": "[ (2) IGNORAR ]", "[ (C) CONFIRM PROFILE ]": "[ (C) CONFIRMAR PERFIL ]", "speed unchanged": "velocidade inalterada", "Session controls unavailable.": "Controlos de sessão indisponíveis.", + "Profile update not yet verified. Codex accepted the request; Codexometer will check again without resending it.": "A atualização do perfil ainda não foi verificada. O Codex aceitou o pedido; o Codexometer verificará novamente sem o reenviar.", "Session skipped for this threshold.": "Sessão ignorada para este limiar.", "Checking / updating…": "A verificar / atualizar…", "Changes remain after Codexometer closes.": "As alterações mantêm-se após fechar o Codexometer.", "unset": "não definido", "Session check failed: %s": "Falha na verificação das sessões: %s", - "Verified %d of %d session updates. No automatic retries.": "Verificadas %d de %d atualizações de sessão. Sem novas tentativas automáticas.", - "Check Codex: %s": "Verifique o Codex: %s", "Expiry order unavailable; backend chooses the credit.": "Ordem de expiração desconhecida; o servidor escolhe o crédito.", "FIRST EXPIRATION IN %s // within %d hours": "PRIMEIRA EXPIRAÇÃO EM %s // dentro de %d horas", "Expiry information unavailable.": "Informações de validade indisponíveis.", diff --git a/internal/i18n/locales/ru.json b/internal/i18n/locales/ru.json index aa9db32..c6b196f 100644 --- a/internal/i18n/locales/ru.json +++ b/internal/i18n/locales/ru.json @@ -1,19 +1,23 @@ { + "WHY THIS CHANGE": "ПРИЧИНА ИЗМЕНЕНИЯ", + "CURRENT PROFILE": "ТЕКУЩИЙ ПРОФИЛЬ", + "PROPOSED PROFILE": "ПРЕДЛАГАЕМЫЙ ПРОФИЛЬ", + "PLEASE NOTE": "ОБРАТИТЕ ВНИМАНИЕ", + "MODEL / REASONING LEVEL / SPEED": "МОДЕЛЬ / УРОВЕНЬ РАССУЖДЕНИЙ / СКОРОСТЬ", "QUOTA THRESHOLD": "ПОРОГ КВОТЫ", - "Quota threshold: %d%%": "Порог квоты: %d%%", + "Your %d%% quota threshold has been reached. Approve the profile switch below for this session's next turns, or skip it.": "Достигнут установленный порог квоты %d%%. Подтвердите смену профиля ниже для следующих ходов этой сессии или пропустите её.", "Refresh quota before approving.": "Обновите квоту перед подтверждением.", "[ (1) APPLY PROFILE ]": "[ (1) ПРИМЕНИТЬ ПРОФИЛЬ ]", "[ (2) SKIP ]": "[ (2) ПРОПУСТИТЬ ]", "[ (C) CONFIRM PROFILE ]": "[ (C) ПОДТВЕРДИТЬ ПРОФИЛЬ ]", "speed unchanged": "скорость не изменяется", "Session controls unavailable.": "Управление сеансами недоступно.", + "Profile update not yet verified. Codex accepted the request; Codexometer will check again without resending it.": "Обновление профиля ещё не подтверждено. Codex принял запрос; Codexometer проверит его снова, не отправляя повторно.", "Session skipped for this threshold.": "Сеанс пропущен для этого порога.", "Checking / updating…": "Проверка / обновление…", "Changes remain after Codexometer closes.": "Изменения сохраняются после закрытия Codexometer.", "unset": "не задано", "Session check failed: %s": "Ошибка проверки сеансов: %s", - "Verified %d of %d session updates. No automatic retries.": "Проверено %d из %d обновлений сеансов. Без автоматических повторов.", - "Check Codex: %s": "Проверьте Codex: %s", "Expiry order unavailable; backend chooses the credit.": "Порядок истечения неизвестен; кредит выбирает сервер.", "FIRST EXPIRATION IN %s // within %d hours": "ПЕРВОЕ ИСТЕЧЕНИЕ ЧЕРЕЗ %s // в течение %d часов", "Expiry information unavailable.": "Сведения о сроках недоступны.", diff --git a/internal/i18n/locales/sv.json b/internal/i18n/locales/sv.json index 26224c2..6f1c498 100644 --- a/internal/i18n/locales/sv.json +++ b/internal/i18n/locales/sv.json @@ -1,19 +1,23 @@ { + "WHY THIS CHANGE": "VARFÖR DENNA ÄNDRING", + "CURRENT PROFILE": "AKTUELL PROFIL", + "PROPOSED PROFILE": "FÖRESLAGEN PROFIL", + "PLEASE NOTE": "OBSERVERA", + "MODEL / REASONING LEVEL / SPEED": "MODELL / RESONEMANGSNIVÅ / HASTIGHET", "QUOTA THRESHOLD": "KVOTGRÄNS", - "Quota threshold: %d%%": "Kvotgräns: %d%%", + "Your %d%% quota threshold has been reached. Approve the profile switch below for this session's next turns, or skip it.": "Din inställda kvotgräns på %d%% har nåtts. Godkänn profilbytet nedan för den här sessionens kommande turer, eller hoppa över det.", "Refresh quota before approving.": "Uppdatera kvoten före godkännande.", "[ (1) APPLY PROFILE ]": "[ (1) TILLÄMPA PROFIL ]", "[ (2) SKIP ]": "[ (2) HOPPA ÖVER ]", "[ (C) CONFIRM PROFILE ]": "[ (C) BEKRÄFTA PROFIL ]", "speed unchanged": "hastigheten oförändrad", "Session controls unavailable.": "Sessionskontroller är inte tillgängliga.", + "Profile update not yet verified. Codex accepted the request; Codexometer will check again without resending it.": "Profiluppdateringen är ännu inte verifierad. Codex har godkänt begäran; Codexometer kontrollerar igen utan att skicka om den.", "Session skipped for this threshold.": "Sessionen hoppades över för denna tröskel.", "Checking / updating…": "Kontrollerar / uppdaterar…", "Changes remain after Codexometer closes.": "Ändringarna kvarstår när Codexometer stängs.", "unset": "inte inställt", "Session check failed: %s": "Sessionskontrollen misslyckades: %s", - "Verified %d of %d session updates. No automatic retries.": "Verifierade %d av %d sessionsuppdateringar. Inga automatiska återförsök.", - "Check Codex: %s": "Kontrollera Codex: %s", "Expiry order unavailable; backend chooses the credit.": "Utgångsordningen är okänd; servern väljer krediten.", "FIRST EXPIRATION IN %s // within %d hours": "FÖRSTA UTGÅNG OM %s // inom %d timmar", "Expiry information unavailable.": "Information om utgångsdatum saknas.", diff --git a/internal/i18n/locales/tr.json b/internal/i18n/locales/tr.json index 15b0744..900b107 100644 --- a/internal/i18n/locales/tr.json +++ b/internal/i18n/locales/tr.json @@ -1,19 +1,23 @@ { + "WHY THIS CHANGE": "BU DEĞİŞİKLİĞİN NEDENİ", + "CURRENT PROFILE": "GEÇERLİ PROFİL", + "PROPOSED PROFILE": "ÖNERİLEN PROFİL", + "PLEASE NOTE": "LÜTFEN DİKKAT", + "MODEL / REASONING LEVEL / SPEED": "MODEL / AKIL YÜRÜTME DÜZEYİ / HIZ", "QUOTA THRESHOLD": "KOTA EŞİĞİ", - "Quota threshold: %d%%": "Kota eşiği: %d%%", + "Your %d%% quota threshold has been reached. Approve the profile switch below for this session's next turns, or skip it.": "Belirlediğiniz %d%% kota eşiğine ulaşıldı. Bu oturumun sonraki turları için aşağıdaki profil değişikliğini onaylayın veya atlayın.", "Refresh quota before approving.": "Onaylamadan önce kotayı yenileyin.", "[ (1) APPLY PROFILE ]": "[ (1) PROFİLİ UYGULA ]", "[ (2) SKIP ]": "[ (2) ATLA ]", "[ (C) CONFIRM PROFILE ]": "[ (C) PROFİLİ ONAYLA ]", "speed unchanged": "hız değişmedi", "Session controls unavailable.": "Oturum denetimleri kullanılamıyor.", + "Profile update not yet verified. Codex accepted the request; Codexometer will check again without resending it.": "Profil güncellemesi henüz doğrulanmadı. Codex isteği kabul etti; Codexometer yeniden göndermeden tekrar kontrol edecek.", "Session skipped for this threshold.": "Bu eşik için oturum atlandı.", "Checking / updating…": "Denetleniyor / güncelleniyor…", "Changes remain after Codexometer closes.": "Codexometer kapandıktan sonra değişiklikler korunur.", "unset": "ayarlanmamış", "Session check failed: %s": "Oturum denetimi başarısız: %s", - "Verified %d of %d session updates. No automatic retries.": "%d / %d oturum güncellemesi doğrulandı. Otomatik yeniden deneme yapılmaz.", - "Check Codex: %s": "Codex'i kontrol edin: %s", "Expiry order unavailable; backend chooses the credit.": "Sona erme sırası bilinmiyor; krediyi sunucu seçer.", "FIRST EXPIRATION IN %s // within %d hours": "İLK SONA ERME %s İÇİNDE // %d saat içinde", "Expiry information unavailable.": "Son kullanma bilgisi yok.", diff --git a/internal/i18n/locales/zh-Hans.json b/internal/i18n/locales/zh-Hans.json index 52e47b7..13f866c 100644 --- a/internal/i18n/locales/zh-Hans.json +++ b/internal/i18n/locales/zh-Hans.json @@ -1,19 +1,23 @@ { + "WHY THIS CHANGE": "更改原因", + "CURRENT PROFILE": "当前配置", + "PROPOSED PROFILE": "建议配置", + "PLEASE NOTE": "请注意", + "MODEL / REASONING LEVEL / SPEED": "模型 / 推理级别 / 速度", "QUOTA THRESHOLD": "配额阈值", - "Quota threshold: %d%%": "配额阈值:%d%%", + "Your %d%% quota threshold has been reached. Approve the profile switch below for this session's next turns, or skip it.": "已达到您设置的 %d%% 配额阈值。请批准下方的配置切换,使其用于此会话的后续轮次,或跳过。", "Refresh quota before approving.": "请先刷新配额再确认。", "[ (1) APPLY PROFILE ]": "[ (1) 应用配置 ]", "[ (2) SKIP ]": "[ (2) 跳过 ]", "[ (C) CONFIRM PROFILE ]": "[ (C) 确认配置 ]", "speed unchanged": "速度不变", "Session controls unavailable.": "会话控制不可用。", + "Profile update not yet verified. Codex accepted the request; Codexometer will check again without resending it.": "配置更新尚未验证。Codex 已接受请求;Codexometer 将重新检查,而不会再次发送。", "Session skipped for this threshold.": "已跳过此阈值下的会话。", "Checking / updating…": "正在检查 / 更新…", "Changes remain after Codexometer closes.": "关闭 Codexometer 后,更改仍会保留。", "unset": "未设置", "Session check failed: %s": "会话检查失败:%s", - "Verified %d of %d session updates. No automatic retries.": "已验证 %d 个会话更新,共 %d 个。不会自动重试。", - "Check Codex: %s": "请检查 Codex:%s", "Expiry order unavailable; backend chooses the credit.": "有效期顺序未知;由后端选择重置额度。", "FIRST EXPIRATION IN %s // within %d hours": "首次过期倒计时 %s // %d 小时内", "Expiry information unavailable.": "有效期信息不可用。", diff --git a/internal/ui/localisation_test.go b/internal/ui/localisation_test.go index 1fa38ae..96f1392 100644 --- a/internal/ui/localisation_test.go +++ b/internal/ui/localisation_test.go @@ -68,6 +68,7 @@ func TestLocalisedScreensHelper(t *testing.T) { t.Run("reset_warning_surfaces", TestResetWarningClickSurfaces) t.Run("reset_warning_language", TestResetWarningsUseLocale) t.Run("quota_profile_controls", TestQuotaLocalisedControls) + t.Run("stacked_session_reviews", TestSessionPillsStackAndOpenDistinctReviews) t.Run("reset_expiry_countdown", TestResetExpiryCountdownAndSpacing) t.Run("header_click_surfaces", TestHeaderClickTargets) t.Run("detail_activity", TestDetailSentWaveLifecycle) diff --git a/internal/ui/model.go b/internal/ui/model.go index 1b8bf2d..0b162ee 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -521,6 +521,9 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } m.quota.sessions = message.sessions m.quota.scanError = "" + for _, id := range message.matched { + delete(m.quota.notices, id) + } if message.err != nil { m.quota.scanError = i18n.Format("Session check failed: %s", message.err.Error()) } @@ -541,12 +544,16 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.quota.handled[s.ID] = message.step.Threshold } m.quota.busySession = "" - notice := i18n.Format("Verified %d of %d session updates. No automatic retries.", message.updated, len(message.targets)) - if message.err != nil { - notice += " " + i18n.Format("Check Codex: %s", message.err.Error()) - } for _, target := range message.targets { - m.setQuotaSessionNotice(target.ID, notice) + if message.err == nil { + delete(m.quota.notices, target.ID) + } else { + notice := i18n.Text("Profile update not yet verified. Codex accepted the request; Codexometer will check again without resending it.") + m.setQuotaSessionNotice(target.ID, notice) + } + } + if message.err != nil { + return m, m.evaluateQuotaStep(m.snapshot) } return m, nil case tea.KeyPressMsg: diff --git a/internal/ui/monitor_approval.go b/internal/ui/monitor_approval.go index 8b55535..266bcc2 100644 --- a/internal/ui/monitor_approval.go +++ b/internal/ui/monitor_approval.go @@ -26,7 +26,7 @@ func (m Model) approvalConfirmationActive(token, action string) bool { func (m Model) monitorApprovalHasOutcome() bool { s, ok := m.contextDetailSession() - return ok && s.preview.Kind == codex.SessionContextApproval && s.preview.ApprovalToken != "" && s.preview.ApprovalToken == m.monitorApprovalNoticeToken && (m.monitorApprovalBusy || m.monitorApprovalNotice != "") + return ok && !m.hasSessionProfile(s) && s.preview.Kind == codex.SessionContextApproval && s.preview.ApprovalToken != "" && s.preview.ApprovalToken == m.monitorApprovalNoticeToken && (m.monitorApprovalBusy || m.monitorApprovalNotice != "") } func (m Model) monitorApprovalBlockReason(c codex.SessionContext) string { @@ -78,7 +78,7 @@ func (m Model) monitorApprovalToken() string { } s, ok := m.contextDetailSession() p, supported := m.fetcher.(codex.SessionApprovalClient) - if !ok || !supported || s.preview.Kind != codex.SessionContextApproval || !p.SessionApprovalPending(s.preview.ApprovalToken) { + if !ok || !supported || m.hasSessionProfile(s) || s.preview.Kind != codex.SessionContextApproval || !p.SessionApprovalPending(s.preview.ApprovalToken) { return "" } return s.preview.ApprovalToken diff --git a/internal/ui/monitor_attention_actions_test.go b/internal/ui/monitor_attention_actions_test.go new file mode 100644 index 0000000..edbd6de --- /dev/null +++ b/internal/ui/monitor_attention_actions_test.go @@ -0,0 +1,113 @@ +package ui + +import ( + "context" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/merefield/codexometer/internal/codex" + "github.com/merefield/codexometer/internal/i18n" +) + +type profileApprovalTestFetcher struct { + *quotaStepTestFetcher + approvalTestClient +} + +func (f *profileApprovalTestFetcher) Fetch(ctx context.Context) (codex.Snapshot, error) { + return f.quotaStepTestFetcher.Fetch(ctx) +} + +func TestSessionPillsStackAndOpenDistinctReviews(t *testing.T) { + m, quota := profileTestModel(t) + f := &profileApprovalTestFetcher{quotaStepTestFetcher: quota, approvalTestClient: approvalTestClient{token: "live"}} + m.fetcher = f + m.width, m.height = 240, 65 + m.monitorSessionData[0].attention = codex.SessionAttentionApproval + m.monitorSessionData[0].preview = approvalTestModel().monitorSessionData[0].preview + items := m.monitorAttentionSessions() + if len(items) != 3 || items[0].action() != "attention:one" || items[1].action() != "attention-profile:one" || items[2].action() != "attention-profile:two" { + t.Fatalf("expected distinct native and profile pills: %#v", items) + } + + click := func(action string) { + t.Helper() + g := m.dashboardLayout() + summary, _, buttons := m.monitorDetailHeader(g.contentWidth, g.meterHeight) + for _, b := range buttons { + if b.action != action { + continue + } + x, y := 2+b.rect.x, g.meterY+summary+b.rect.y + if got := m.monitorContextAt(x, y); got != action { + t.Fatalf("hit %q, want %q", got, action) + } + next, cmd, handled := m.updateMonitorContextMouse(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft}) + if cmd != nil || !handled { + t.Fatal("navigation sent an action") + } + m = next + return + } + t.Fatalf("missing pill %q", action) + } + + click("attention-profile:one") + if !m.hasSessionProfile(m.monitorSessionData[0]) || m.monitorApprovalToken() != "" || len(m.visibleMonitorApprovalButtons()) != 0 { + t.Fatal("quota pill must show quota controls only") + } + if !strings.Contains(strings.Join(m.contextDetailLines(200), "\n"), i18n.Format("Your %d%% quota threshold has been reached. Approve the profile switch below for this session's next turns, or skip it.", 80)) { + t.Fatal("wrong document") + } + buttons, _ := m.monitorAttentionButtons(236, 1) + for _, b := range buttons { + if strings.HasPrefix(b.label, ">") != (b.action == "attention-profile:one") { + t.Fatalf("wrong active marker: %#v", b) + } + } + m, _, _ = m.updateProfileKey("1") + if !m.profileArmed("one") { + t.Fatal("quota confirmation not armed") + } + click("attention:one") + if m.profileArmed("one") || m.hasSessionProfile(m.monitorSessionData[0]) || m.monitorApprovalToken() != "live" { + t.Fatal("native pill did not switch document/disarm quota confirmation") + } + if len(m.visibleProfileButtons()) != 0 || len(m.visibleMonitorApprovalButtons()) == 0 { + t.Fatal("wrong controls for Codex approval") + } + if !strings.Contains(strings.Join(m.contextDetailLines(200), "\n"), "git push") { + t.Fatal("approval command missing") + } + if quota.updates != 0 || f.decision != "" { + t.Fatal("switching pills wrote settings or approved a command") + } +} + +func TestSessionPillCompletionSuppressionAndIndependentResolution(t *testing.T) { + m, _ := profileTestModel(t) + m.monitorSessionData = m.monitorSessionData[:1] + m.monitorSessionData[0].attention = codex.SessionAttentionComplete + if items := m.monitorAttentionSessions(); len(items) != 1 || !items[0].profile { + t.Fatal("completion duplicates outstanding quota review") + } + m.monitorSessionData[0].attention = codex.SessionAttentionInput + items := m.monitorAttentionSessions() + if len(items) != 2 || items[0].profile || !items[1].profile { + t.Fatal("input and quota should coexist") + } + m.openMonitorAttention("attention-profile:one") + m.setRowContext("one", contextWide) + if !m.hasSessionProfile(m.monitorSessionData[0]) { + t.Fatal("leaving full detail lost chosen review") + } + m.declineQuotaSession("one") + if items = m.monitorAttentionSessions(); len(items) != 1 || items[0].profile { + t.Fatal("skipping quota removed native input pill") + } + m.monitorSessionData[0].attention = codex.SessionAttentionComplete + if items = m.monitorAttentionSessions(); len(items) != 1 || items[0].profile { + t.Fatal("completion did not return after quota was handled") + } +} diff --git a/internal/ui/monitor_context.go b/internal/ui/monitor_context.go index caf85f2..434e4d6 100644 --- a/internal/ui/monitor_context.go +++ b/internal/ui/monitor_context.go @@ -449,16 +449,8 @@ func (m Model) updateMonitorContextMouse(msg tea.MouseMsg) (Model, tea.Cmd, bool default: if page, ok := strings.CutPrefix(m.monitorContextHover, "attention-next:"); ok { m.monitorAttentionPage, _ = strconv.Atoi(page) - } else if id, ok := strings.CutPrefix(m.monitorContextHover, "attention:"); ok { - if id == m.monitorContextDetail { - return m, nil, true - } - for _, s := range m.monitorAttentionSessions() { - if s.id == id { - m.setRowContext(id, contextFull) - break - } - } + } else if strings.HasPrefix(m.monitorContextHover, "attention:") || strings.HasPrefix(m.monitorContextHover, "attention-profile:") { + m.openMonitorAttention(m.monitorContextHover) } else if id, ok := strings.CutPrefix(m.monitorContextHover, "detail:"); ok { m.openMonitorContext(id) } else if id, ok := strings.CutPrefix(m.monitorContextHover, "less:"); ok { diff --git a/internal/ui/monitor_context_modes.go b/internal/ui/monitor_context_modes.go index efb1317..745c799 100644 --- a/internal/ui/monitor_context_modes.go +++ b/internal/ui/monitor_context_modes.go @@ -75,7 +75,8 @@ func (m *Model) stepBackMonitorContext() { } type rowContextState struct { - mode int + mode int + review string // empty: automatic; context or profile: explicitly selected pill } const ( @@ -111,7 +112,7 @@ func (m *Model) setRowContext(id string, mode int) { if m.monitorContextRows == nil { m.monitorContextRows = make(map[string]rowContextState) } - m.monitorContextRows[id] = rowContextState{mode: min(max(mode, contextGraph), contextWide)} + m.monitorContextRows[id] = rowContextState{mode: min(max(mode, contextGraph), contextWide), review: m.monitorContextRows[id].review} m.monitorSelectedID = id m.monitorContextDetail, m.monitorContextExpanded = "", "" if mode == contextFull { @@ -204,7 +205,7 @@ func (m Model) renderExpandedContext(width, height int, s monitorSession, colors // be safely offered. The warning never grants approval; it only opens detail. func (m Model) expandedContextNavigation(width, height int, s monitorSession) []monitorNavigationButton { buttons := m.monitorNavigationButtons(width, s.id, false) - if s.preview.Kind != codex.SessionContextApproval || len(m.expandedApprovalButtons(width, height, s)) > 0 || + if m.hasSessionProfile(s) || s.preview.Kind != codex.SessionContextApproval || len(m.expandedApprovalButtons(width, height, s)) > 0 || (s.id == m.monitorContextTarget() && m.monitorApprovalHasOutcome()) { return buttons } diff --git a/internal/ui/monitor_profile.go b/internal/ui/monitor_profile.go index 0afda6f..1a47a46 100644 --- a/internal/ui/monitor_profile.go +++ b/internal/ui/monitor_profile.go @@ -11,16 +11,9 @@ import ( "github.com/merefield/codexometer/internal/i18n" ) -// Profile suggestions are local reviews, not synthetic Codex approval tokens. -// Real approvals/questions always retain their original detail and controls. -func (m Model) sessionProfile(s monitorSession) (codex.QuotaSession, bool) { - if m.monitorPrompt.session == s.id && (m.monitorPrompt.input.Focused() || m.monitorPrompt.busy) { - return codex.QuotaSession{}, false - } - if s.attention == codex.SessionAttentionApproval || s.attention == codex.SessionAttentionInput || - s.preview.Kind == codex.SessionContextApproval || s.preview.Kind == codex.SessionContextQuestion { - return codex.QuotaSession{}, false - } +// Eligibility is independent of the detail being viewed: one session may have +// a Codex request and a separate quota review at the same time. +func (m Model) quotaSessionCandidate(s monitorSession) (codex.QuotaSession, bool) { if m.quotaStepPending == nil { return codex.QuotaSession{}, false } @@ -35,6 +28,22 @@ func (m Model) sessionProfile(s monitorSession) (codex.QuotaSession, bool) { return codex.QuotaSession{}, false } +// Default to Codex requests, but respect an explicit click on a quota pill. +func (m Model) sessionProfile(s monitorSession) (codex.QuotaSession, bool) { + if m.monitorPrompt.session == s.id && (m.monitorPrompt.input.Focused() || m.monitorPrompt.busy) { + return codex.QuotaSession{}, false + } + review := m.monitorContextRows[s.id].review + if review == "context" { + return codex.QuotaSession{}, false + } + if review != "profile" && (s.attention == codex.SessionAttentionApproval || s.attention == codex.SessionAttentionInput || + s.preview.Kind == codex.SessionContextApproval || s.preview.Kind == codex.SessionContextQuestion) { + return codex.QuotaSession{}, false + } + return m.quotaSessionCandidate(s) +} + func (m Model) hasSessionProfile(s monitorSession) bool { _, ok := m.sessionProfile(s); return ok } func (m Model) profileDocument(s monitorSession, width int) []detailLine { @@ -46,18 +55,36 @@ func (m Model) profileDocument(s monitorSession, width int) []detailLine { if current.Tier != nil { speed = *current.Tier } - text := terminalLabel(s.id) + " // " + terminalLabel(s.workingDirectory) + "\n\n" + - i18n.Format("Quota threshold: %d%%", m.quotaStepPending.Threshold) + "\n" + - current.Model + " / " + current.Effort + " / " + speed + "\n→ " + quotaStepProfile(*m.quotaStepPending) + "\n\n" + - i18n.Text("Changes remain after Codexometer closes.") + width = max(width, 1) + var lines []detailLine + appendText := func(text, kind string) { + for _, line := range strings.Split(ansi.Hardwrap(codex.SanitizeSessionContext(text), width, true), "\n") { + lines = append(lines, detailLine{ansi.Truncate(line, width, ""), kind}) + } + } + section := func(title string) { + lines = append(lines, detailLine{}) + title = ansi.Truncate(title, width, "") + if rule := width - lipgloss.Width(title) - 1; rule > 0 { + title += " " + strings.Repeat("─", rule) + } + lines = append(lines, detailLine{title, "heading"}) + } + appendText(terminalLabel(s.id)+" // "+terminalLabel(s.workingDirectory), "metadata") + section(i18n.Text("WHY THIS CHANGE")) + appendText(i18n.Format("Your %d%% quota threshold has been reached. Approve the profile switch below for this session's next turns, or skip it.", m.quotaStepPending.Threshold), "body") + section(i18n.Text("CURRENT PROFILE")) + appendText(i18n.Text("MODEL / REASONING LEVEL / SPEED"), "metadata") + appendText(current.Model+" / "+current.Effort+" / "+speed, "body") + section(i18n.Text("PROPOSED PROFILE")) + appendText(i18n.Text("MODEL / REASONING LEVEL / SPEED"), "metadata") + appendText(quotaStepProfile(*m.quotaStepPending), "body") + section(i18n.Text("PLEASE NOTE")) + appendText(i18n.Text("Changes remain after Codexometer closes."), "warning") if m.quota.busySession == s.id { - text += "\n" + i18n.Text("Checking / updating…") + appendText(i18n.Text("Checking / updating…"), "body") } else if !m.quotaFresh() { - text += "\n" + i18n.Text("Refresh quota before approving.") - } - var lines []detailLine - for _, line := range strings.Split(ansi.Hardwrap(codex.SanitizeSessionContext(text), max(width, 1), true), "\n") { - lines = append(lines, detailLine{ansi.Truncate(line, max(width, 1), ""), "warning"}) + appendText(i18n.Text("Refresh quota before approving."), "warning") } return lines } @@ -144,7 +171,7 @@ func (m Model) renderSessionProfile(width, height int, s monitorSession, colors navigation = "" } } - return frameSizedWithActions(width, max(height-2, 1), i18n.Text("QUOTA THRESHOLD"), navigation, "", strings.Join(body, "\n"), colors.warning, colors) + return frameSizedWithActions(width, max(height-2, 1), i18n.Text("QUOTA THRESHOLD"), navigation, "", strings.Join(body, "\n"), colors.primary, colors) } func profileButtonsAt(buttons []monitorApprovalButton, height, x, y int) string { diff --git a/internal/ui/monitor_profile_test.go b/internal/ui/monitor_profile_test.go index 5c81e3c..41cbe02 100644 --- a/internal/ui/monitor_profile_test.go +++ b/internal/ui/monitor_profile_test.go @@ -1,6 +1,7 @@ package ui import ( + "errors" "strings" "testing" "time" @@ -45,8 +46,8 @@ func TestProfileReviewsAreIndividual(t *testing.T) { if got := m.quotaCandidates(); len(got) != 1 || got[0].ID != "two" { t.Fatalf("other session lost review: %#v", got) } - if m.quota.notices["one"] == "" || m.quota.notices["two"] != "" { - t.Fatal("feedback leaked to another session") + if m.quota.notices["one"] != "" || m.quota.notices["two"] != "" { + t.Fatal("successful profile update left stale feedback") } m.setRowContext("two", contextFull) m, cmd, _ = m.updateProfileKey("2") @@ -55,6 +56,43 @@ func TestProfileReviewsAreIndividual(t *testing.T) { } } +func TestProfileReviewExplainsThresholdAndChoice(t *testing.T) { + m, _ := profileTestModel(t) + var text []string + for _, line := range m.profileDocument(m.monitorSessionData[0], 200) { + text = append(text, line.text) + } + want := i18n.Format("Your %d%% quota threshold has been reached. Approve the profile switch below for this session's next turns, or skip it.", 80) + if !strings.Contains(strings.Join(text, "\n"), want) { + t.Fatal("review must explain the configured threshold and the user's choice") + } + for _, profile := range []string{"large / high / " + i18n.Text("unset"), "small / medium / " + i18n.Text("speed unchanged")} { + if !strings.Contains(strings.Join(text, "\n"), i18n.Text("MODEL / REASONING LEVEL / SPEED")+"\n"+profile) { + t.Fatal("each profile must have a field heading and remain left aligned") + } + } + var headings []string + warnings := 0 + for index, line := range m.profileDocument(m.monitorSessionData[0], 200) { + if line.kind == "heading" { + if index == 0 || text[index-1] != "" { + t.Fatal("section needs a blank line") + } + headings = append(headings, strings.TrimRight(line.text, " ─")) + } + if line.kind == "warning" { + warnings++ + if line.text != i18n.Text("Changes remain after Codexometer closes.") { + t.Fatal("ordinary review text should not use warning colour") + } + } + } + wantHeadings := []string{i18n.Text("WHY THIS CHANGE"), i18n.Text("CURRENT PROFILE"), i18n.Text("PROPOSED PROFILE"), i18n.Text("PLEASE NOTE")} + if strings.Join(headings, "|") != strings.Join(wantHeadings, "|") || warnings != 1 { + t.Fatalf("incorrect sections or warning emphasis: %v, %d", headings, warnings) + } +} + func TestProfileNativeAttentionAndHiddenControls(t *testing.T) { for _, kind := range []string{"approval", "input", "question", "graph", "short", "paused", "error", "stale", "focused"} { t.Run(kind, func(t *testing.T) { @@ -107,6 +145,27 @@ func TestProfileConfirmationCannotFollowSelection(t *testing.T) { } } +func TestUnverifiedProfileNoticeClosesAfterAuthoritativeRescan(t *testing.T) { + m, f := profileTestModel(t) + target := m.quotaCandidates()[0] + f.sessions[0].Model = "small" + f.sessions[0].Effort = "medium" + message := quotaStepResult{ + revision: m.quota.revision, step: *m.quotaStepPending, window: m.quotaStepWindow, + targets: []codex.QuotaSession{target}, err: errors.New("settings queued but application not verified"), + } + next, cmd := m.Update(message) + m = next.(Model) + if cmd == nil || m.quota.notices["one"] == "" { + t.Fatal("uncertain result was not scheduled for reconciliation") + } + next, _ = m.Update(cmd()) + m = next.(Model) + if m.quota.notices["one"] != "" || f.updates != 0 { + t.Fatal("verified rescan retained notice or resent the write") + } +} + func TestProfileZeroThresholdAndNoQuotaChrome(t *testing.T) { m, _ := profileTestModel(t) m.quotaStepPending.Threshold = 0 diff --git a/internal/ui/monitor_summary.go b/internal/ui/monitor_summary.go index fcba324..f2ef4d1 100644 --- a/internal/ui/monitor_summary.go +++ b/internal/ui/monitor_summary.go @@ -80,30 +80,68 @@ func (m Model) monitorSummaryLines(width, rows int, colors palette) []string { return lines } -func (m Model) monitorAttentionSessions() []monitorSession { +type monitorAttentionItem struct { + monitorSession + profile bool +} + +func (item monitorAttentionItem) action() string { + if item.profile { + return "attention-profile:" + item.id + } + return "attention:" + item.id +} + +func (m Model) monitorAttentionSessions() []monitorAttentionItem { if m.monitorState != monitorRunning || m.monitorError != "" { return nil } - var sessions []monitorSession - for _, priority := range []int{0, 1, 2, 3} { + var items []monitorAttentionItem + for priority := 0; priority < 4; priority++ { for _, s := range m.monitorSessionData { - p := -1 - switch { - case s.attention == codex.SessionAttentionApproval: - p = 0 - case s.attention == codex.SessionAttentionInput: - p = 1 - case m.hasSessionProfile(s): - p = 2 - case s.attention == codex.SessionAttentionComplete && !s.working: - p = 3 + if !m.monitorSessionVisible(s) { + continue } - if m.monitorSessionVisible(s) && p == priority { - sessions = append(sessions, s) + _, profile := m.quotaSessionCandidate(s) + include := false + switch priority { + case 0: + include = s.attention == codex.SessionAttentionApproval + case 1: + include = s.attention == codex.SessionAttentionInput + case 2: + include = profile + case 3: + include = s.attention == codex.SessionAttentionComplete && !s.working && !profile + } + if include { + items = append(items, monitorAttentionItem{monitorSession: s, profile: priority == 2}) } } } - return sessions + return items +} + +// Validate the action, not only the session ID; sibling pills must open +// different documents and disarm each other's confirmation. +func (m *Model) openMonitorAttention(action string) { + for _, item := range m.monitorAttentionSessions() { + if item.action() != action { + continue + } + review := "context" + if item.profile { + review = "profile" + } + if m.monitorContextDetail == item.id && m.selectedAttentionAction() == action { + return + } + m.setRowContext(item.id, contextFull) + row := m.monitorContextRows[item.id] + row.review = review + m.monitorContextRows[item.id] = row + return + } } // A bounded, paged flow layout shared by rendering and hit testing. IDs remain @@ -142,20 +180,31 @@ func (m Model) monitorAttentionButtons(width, maxRows int) ([]monitorNavigationB return buttons, rows } +func (m Model) selectedAttentionAction() string { + id := m.monitorContextDetail + if id == "" { + return "" + } + if s, ok := m.contextDetailSession(); ok && m.hasSessionProfile(s) { + return "attention-profile:" + id + } + return "attention:" + id +} + func (m Model) markSelectedAttention(buttons []monitorNavigationButton) { - selected := m.monitorContextDetail - if selected == "" { + action := m.selectedAttentionAction() + if action == "" { return } for i := range buttons { - if buttons[i].action == "attention:"+selected { + if buttons[i].action == action { label := buttons[i].label buttons[i].label = ">" + label[1:len(label)-1] + "<" } } } -func (m Model) layoutMonitorAttention(sessions []monitorSession, width, rows, compact int, truncate bool) []monitorNavigationButton { +func (m Model) layoutMonitorAttention(sessions []monitorAttentionItem, width, rows, compact int, truncate bool) []monitorNavigationButton { x, y := 0, 0 markerWidth := 0 if m.monitorContextDetail != "" { @@ -165,10 +214,10 @@ func (m Model) layoutMonitorAttention(sessions []monitorSession, width, rows, co for _, s := range sessions { id := shortSessionID(s.id) state := monitorAttentionStatus(s.attention) - if m.hasSessionProfile(s) { + if s.profile { state = i18n.Text("QUOTA THRESHOLD") } - if compact == 3 && s.attention == codex.SessionAttentionComplete && !m.hasSessionProfile(s) { + if compact == 3 && s.attention == codex.SessionAttentionComplete && !s.profile { state = i18n.Text("DONE") } if truncate { @@ -199,7 +248,7 @@ func (m Model) layoutMonitorAttention(sessions []monitorSession, width, rows, co if y >= rows { break } - buttons = append(buttons, monitorNavigationButton{label: label, action: "attention:" + s.id, enabled: true, rect: monitorRect{x: x, y: y, width: w, height: 1}}) + buttons = append(buttons, monitorNavigationButton{label: label, action: s.action(), enabled: true, rect: monitorRect{x: x, y: y, width: w, height: 1}}) x += w + 1 } return buttons @@ -209,8 +258,8 @@ func (m Model) renderMonitorAttention(width, rows int, buttons []monitorNavigati lines := make([]string, rows) completed := make(map[string]bool) for _, s := range m.monitorAttentionSessions() { - if s.attention == codex.SessionAttentionComplete && !m.hasSessionProfile(s) { - completed["attention:"+s.id] = true + if s.attention == codex.SessionAttentionComplete && !s.profile { + completed[s.action()] = true } } for _, b := range buttons { diff --git a/internal/ui/quota_step_down.go b/internal/ui/quota_step_down.go index 304cb05..2ad7988 100644 --- a/internal/ui/quota_step_down.go +++ b/internal/ui/quota_step_down.go @@ -34,6 +34,7 @@ type quotaScanResult struct { revision uint64 step codex.QuotaStep sessions []codex.QuotaSession + matched []string err error } type quotaStepResult struct { @@ -159,12 +160,15 @@ func (m *Model) evaluateQuotaStep(snapshot codex.Snapshot) tea.Cmd { } sessions, err := c.QuotaSessions(ctx) var candidates []codex.QuotaSession + var matched []string for _, session := range sessions { - if !session.MatchesQuotaStep(resolved) { + if session.MatchesQuotaStep(resolved) { + matched = append(matched, session.ID) + } else { candidates = append(candidates, session) } } - return quotaScanResult{revision: revision, step: step, sessions: candidates, err: err} + return quotaScanResult{revision: revision, step: step, sessions: candidates, matched: matched, err: err} } } func (m Model) quotaCandidates() []codex.QuotaSession { diff --git a/internal/ui/quota_step_down_test.go b/internal/ui/quota_step_down_test.go index d40a9ac..7eb3e49 100644 --- a/internal/ui/quota_step_down_test.go +++ b/internal/ui/quota_step_down_test.go @@ -213,7 +213,10 @@ func TestQuotaRestartUsesCurrentSettingsWithoutApprovalHistory(t *testing.T) { f.sessions[i].Effort = "medium" f.sessions[i].Tier = &priority if speed == "default" { - f.sessions[i].Tier = nil + // Reproduce app-server's reconnect representation: standard + // routing may be returned explicitly rather than as null. + standard := "default" + f.sessions[i].Tier = &standard } } restarted := New(f, time.Minute) diff --git a/intro-post.md b/intro-post.md index 4c527a6..739cad0 100644 --- a/intro-post.md +++ b/intro-post.md @@ -51,8 +51,10 @@ keep priority. These shortcuts navigate only; they never approve or send for you Optional quota step-down profiles also join the terminal session command centre: configure `--quota-step-down PERCENT:MODEL:EFFORT[:SPEED]`, then follow a **QUOTA THRESHOLD** pill to review the proposed settings for that session. -Apply and confirm individually, or skip; Codex's own approval/input requests -take priority. Nothing changes automatically, and approved settings are not +Apply and confirm individually, or skip. A session can have separate pills for +its Codex request and quota review; each opens the corresponding detail, with +Codex requests ordered first. Completion pills wait until outstanding reviews +are handled. Nothing changes automatically, and approved settings are not rolled back when Codexometer closes. Start with the session that needs you. A **WORKING** badge in its telemetry box blinks only its ball, keeping the text steady; completion and attention states take priority. Recent activity alone is not treated as proof of work. Compact and wide detail panels keep content headings such as **LAST REPLY** or **APPROVAL REQUEST**, while full detail puts the session status in its box title without repeating it in the body. From 66712e48c781af320b217769a6f6fa94beb82917 Mon Sep 17 00:00:00 2001 From: merefield Date: Fri, 18 Sep 2026 11:12:21 +0100 Subject: [PATCH 3/9] FIX: address session profile review edge cases --- internal/codex/daemon_status_unix.go | 40 +++--- internal/codex/quota_step.go | 5 + internal/codex/session_settings_unix.go | 16 ++- internal/codex/session_settings_unix_test.go | 19 +++ internal/ui/model.go | 13 +- internal/ui/monitor_detail.go | 8 +- internal/ui/monitor_summary.go | 3 + internal/ui/profile_review_regression_test.go | 115 ++++++++++++++++++ internal/ui/quota_step_down.go | 15 +++ 9 files changed, 211 insertions(+), 23 deletions(-) create mode 100644 internal/ui/profile_review_regression_test.go diff --git a/internal/codex/daemon_status_unix.go b/internal/codex/daemon_status_unix.go index 91bba67..e5fb1d1 100644 --- a/internal/codex/daemon_status_unix.go +++ b/internal/codex/daemon_status_unix.go @@ -25,22 +25,24 @@ type daemonStatusProvider struct { contexts map[string]*daemonContextState socketPath string - mu sync.Mutex - connection *websocket.Conn - nextRequestID int64 - pending map[int64]chan daemonEnvelope - subscribed map[string]struct{} - reroutedTurns map[daemonTurnKey]string - observations []resolvedModelObservation - nextSequence uint64 - lastStatusAt time.Time - statusThreads map[string]struct{} - statuses map[string]sessionRuntimeStatus - threadSettings map[string]QuotaSession - settingsSignal chan struct{} - settingsMu sync.Mutex - settingsClosed bool - writeMu sync.Mutex + mu sync.Mutex + connection *websocket.Conn + nextRequestID int64 + pending map[int64]chan daemonEnvelope + subscribed map[string]struct{} + reroutedTurns map[daemonTurnKey]string + observations []resolvedModelObservation + nextSequence uint64 + lastStatusAt time.Time + statusThreads map[string]struct{} + statuses map[string]sessionRuntimeStatus + threadSettings map[string]QuotaSession + settingsVersions map[string]uint64 + settingsVersion uint64 + settingsSignal chan struct{} + settingsMu sync.Mutex + settingsClosed bool + writeMu sync.Mutex } type daemonTurnKey struct { @@ -240,6 +242,7 @@ func (p *daemonStatusProvider) ensureConnected(ctx context.Context) error { p.subscribed = make(map[string]struct{}) p.reroutedTurns = make(map[daemonTurnKey]string) p.threadSettings = make(map[string]QuotaSession) + p.settingsVersions = make(map[string]uint64) p.settingsSignal = make(chan struct{}) p.mu.Unlock() go p.readLoop(connection) @@ -420,6 +423,11 @@ func (p *daemonStatusProvider) handleNotification(method string, params json.Raw ID: notification.ThreadID, Model: notification.ThreadSettings.Model, Effort: notification.ThreadSettings.Effort, Tier: notification.ThreadSettings.ServiceTier, } + if p.settingsVersions == nil { + p.settingsVersions = make(map[string]uint64) + } + p.settingsVersion++ + p.settingsVersions[notification.ThreadID] = p.settingsVersion if p.settingsSignal != nil { close(p.settingsSignal) } diff --git a/internal/codex/quota_step.go b/internal/codex/quota_step.go index aa061ee..36dbc5a 100644 --- a/internal/codex/quota_step.go +++ b/internal/codex/quota_step.go @@ -13,6 +13,11 @@ type QuotaStep struct { // An advertised tier name or ID; empty preserves speed. ServiceTier string } + +// ErrQuotaProfileUnverified means the server acknowledged the write but its +// application could not be confirmed. It must not trigger an automatic retry. +var ErrQuotaProfileUnverified = errors.New("profile update accepted but not verified") + type QuotaSession struct { ID string Model string diff --git a/internal/codex/session_settings_unix.go b/internal/codex/session_settings_unix.go index b3a5161..56ab92d 100644 --- a/internal/codex/session_settings_unix.go +++ b/internal/codex/session_settings_unix.go @@ -19,11 +19,11 @@ func sameQuotaSettings(a, b QuotaSession) bool { return a.Model == b.Model && a.Effort == b.Effort && tierEqual(a.Tier, b.Tier) } -func (p *daemonStatusProvider) observedQuotaSettings(id string) (QuotaSession, <-chan struct{}, bool) { +func (p *daemonStatusProvider) observedQuotaSettings(id string) (QuotaSession, <-chan struct{}, uint64, bool) { p.mu.Lock() defer p.mu.Unlock() current, ok := p.threadSettings[id] - return current, p.settingsSignal, ok + return current, p.settingsSignal, p.settingsVersions[id], ok } // Only call for a thread positively observed as loaded. No settings overrides @@ -145,18 +145,24 @@ func (p *daemonStatusProvider) validateQuotaStep(ctx context.Context, step Quota // The RPC acknowledgement means queued, not applied. Observe the actual // configured settings before calling an update successful. -func (p *daemonStatusProvider) writeQuotaSettings(ctx context.Context, expected QuotaSession, params map[string]any) error { +func (p *daemonStatusProvider) writeQuotaSettings(ctx context.Context, expected QuotaSession, params map[string]any) (err error) { if err := ctx.Err(); err != nil { return err } + _, _, before, _ := p.observedQuotaSettings(expected.ID) if err := p.request(ctx, "thread/settings/update", params, nil); err != nil { return err } + defer func() { + if err != nil { + err = errors.Join(ErrQuotaProfileUnverified, err) + } + }() deadline := time.NewTimer(3 * time.Second) defer deadline.Stop() for { - observed, changed, ok := p.observedQuotaSettings(expected.ID) - if ok && sameQuotaSettings(observed, expected) { + observed, changed, version, ok := p.observedQuotaSettings(expected.ID) + if ok && version > before && sameQuotaSettings(observed, expected) { return nil } loaded, err := p.loadedThreads(ctx) diff --git a/internal/codex/session_settings_unix_test.go b/internal/codex/session_settings_unix_test.go index 6eea30c..901a2fa 100644 --- a/internal/codex/session_settings_unix_test.go +++ b/internal/codex/session_settings_unix_test.go @@ -339,6 +339,25 @@ func TestQuotaSettingsQueuedAcknowledgementIsNotSuccess(t *testing.T) { } } +func TestQuotaSettingsOldNotificationCannotVerifyNewWrite(t *testing.T) { + p, f := newQuotaDaemon(t) + sessions, err := p.QuotaSessions(context.Background()) + if err != nil { + t.Fatal(err) + } + p.handleNotification("thread/settings/updated", json.RawMessage(`{"threadId":"one","threadSettings":{"model":"small","effort":"medium","serviceTier":null}}`)) + // Live readback has drifted from the previously observed target. + f.mu.Lock() + f.queued = true + f.mu.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + n, err := p.ApplyQuotaProfile(ctx, sessions[:1], QuotaStep{Model: "small", Effort: "medium"}) + if n != 0 || !errors.Is(err, ErrQuotaProfileUnverified) || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("stale observation verified write: %d %v", n, err) + } +} + func TestQuotaSettingsUpdatedNotificationVerifiesQueuedWrite(t *testing.T) { p, f := newQuotaDaemon(t) sessions, _ := p.QuotaSessions(context.Background()) diff --git a/internal/ui/model.go b/internal/ui/model.go index 0b162ee..8072ab9 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -523,6 +523,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.quota.scanError = "" for _, id := range message.matched { delete(m.quota.notices, id) + m.clearQuotaReviewChoice(id) } if message.err != nil { m.quota.scanError = i18n.Format("Session check failed: %s", message.err.Error()) @@ -536,19 +537,29 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { if message.window != m.quotaStepWindow { return m, nil } + if m.quotaStepPending == nil || *m.quotaStepPending != message.step { + m.quota.busySession = "" + m.quota.sessions = nil + m.clearQuotaReviewChoice("") + return m, m.evaluateQuotaStep(m.snapshot) + } if m.quota.handled == nil { m.quota.handled = map[string]int{} } // No automatic retry, including after partial or uncertain outcomes. for _, s := range message.targets { m.quota.handled[s.ID] = message.step.Threshold + m.clearQuotaReviewChoice(s.ID) } m.quota.busySession = "" for _, target := range message.targets { if message.err == nil { delete(m.quota.notices, target.ID) } else { - notice := i18n.Text("Profile update not yet verified. Codex accepted the request; Codexometer will check again without resending it.") + notice := i18n.Format("Session check failed: %s", codex.SanitizeSessionContext(message.err.Error())) + if errors.Is(message.err, codex.ErrQuotaProfileUnverified) { + notice = i18n.Text("Profile update not yet verified. Codex accepted the request; Codexometer will check again without resending it.") + } m.setQuotaSessionNotice(target.ID, notice) } } diff --git a/internal/ui/monitor_detail.go b/internal/ui/monitor_detail.go index 6d870db..0a58090 100644 --- a/internal/ui/monitor_detail.go +++ b/internal/ui/monitor_detail.go @@ -46,7 +46,13 @@ func (m Model) contextDetailDocument(width int) []detailLine { for _, line := range strings.Split(ansi.Hardwrap(identity, width, true), "\n") { lines = append(lines, detailLine{ansi.Truncate(line, width, ""), "metadata"}) } - return append(lines, detailLine{i18n.Text("NO CONTEXT"), "metadata"}, detailLine{codex.SanitizeSessionContext(m.quota.notices[s.id]), "warning"}) + lines = append(lines, detailLine{i18n.Text("NO CONTEXT"), "metadata"}) + if notice := codex.SanitizeSessionContext(m.quota.notices[s.id]); notice != "" { + for _, line := range strings.Split(ansi.Hardwrap(notice, width, true), "\n") { + lines = append(lines, detailLine{ansi.Truncate(line, width, ""), "warning"}) + } + } + return lines } c := s.preview var lines []detailLine diff --git a/internal/ui/monitor_summary.go b/internal/ui/monitor_summary.go index f2ef4d1..356718b 100644 --- a/internal/ui/monitor_summary.go +++ b/internal/ui/monitor_summary.go @@ -125,6 +125,9 @@ func (m Model) monitorAttentionSessions() []monitorAttentionItem { // Validate the action, not only the session ID; sibling pills must open // different documents and disarm each other's confirmation. func (m *Model) openMonitorAttention(action string) { + if strings.HasPrefix(action, "attention-profile:") && (m.monitorPrompt.input.Focused() || m.monitorPrompt.busy) { + return + } for _, item := range m.monitorAttentionSessions() { if item.action() != action { continue diff --git a/internal/ui/profile_review_regression_test.go b/internal/ui/profile_review_regression_test.go new file mode 100644 index 0000000..368fb4d --- /dev/null +++ b/internal/ui/profile_review_regression_test.go @@ -0,0 +1,115 @@ +package ui + +import ( + "errors" + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/merefield/codexometer/internal/codex" +) + +func TestProfileResultAfterThresholdChangeRescans(t *testing.T) { + m, f := profileTestModel(t) + m, _ = m.pressQuotaSession("one", false) + m, apply := m.pressQuotaSession("one", true) + m.snapshot.RateLimits.Secondary.UsedPercent = 96 + if cmd := m.evaluateQuotaStep(m.snapshot); cmd != nil { + t.Fatal("scan during apply") + } + next, scan := m.Update(apply()) + m = next.(Model) + if scan == nil || len(m.quota.handled) != 0 || len(m.quota.sessions) != 0 { + t.Fatal("old result accepted under new threshold") + } + next, _ = m.Update(scan()) + m = next.(Model) + for _, s := range m.quotaCandidates() { + if s.ID == "one" && (s.Model != "small" || s.Effort != "medium") { + t.Fatal("stale current profile") + } + } + if f.updates != 1 { + t.Fatal("unexpected retry") + } +} + +func TestProfileFailureNoticeDistinguishesAcceptance(t *testing.T) { + for _, err := range []error{errors.New("settings changed since review; skipped"), codex.ErrQuotaProfileUnverified} { + m, _ := profileTestModel(t) + next, _ := m.Update(quotaStepResult{revision: m.quota.revision, step: *m.quotaStepPending, window: m.quotaStepWindow, targets: m.quotaCandidates()[:1], err: err}) + notice := next.(Model).quota.notices["one"] + if strings.Contains(notice, "Codex accepted") != errors.Is(err, codex.ErrQuotaProfileUnverified) { + t.Fatalf("wrong notice: %s", notice) + } + if !errors.Is(err, codex.ErrQuotaProfileUnverified) && !strings.Contains(notice, err.Error()) { + t.Fatal("lost rejection cause") + } + } +} + +func TestProfileNoticeWithoutContextWraps(t *testing.T) { + m, _ := profileTestModel(t) + m.quotaStepPending = nil + m.monitorSessionData[0].preview = codex.SessionContext{} + before := m.contextDetailDocument(15) + if before[len(before)-1].kind == "warning" { + t.Fatal("empty warning row") + } + m.setQuotaSessionNotice("one", strings.Repeat("long notice ", 12)) + after := m.contextDetailDocument(15) + if len(after) <= len(before)+1 { + t.Fatal("notice not wrapped") + } + for _, line := range after { + if lipgloss.Width(line.text) > 15 { + t.Fatal("notice overflow") + } + } +} + +func TestProfileChoiceClearsOnResolutionAndThresholdChange(t *testing.T) { + for _, event := range []string{"skip", "apply", "threshold", "window", "matched"} { + t.Run(event, func(t *testing.T) { + m, _ := profileTestModel(t) + m.openMonitorAttention("attention-profile:one") + switch event { + case "skip": + m.declineQuotaSession("one") + case "apply": + next, _ := m.Update(quotaStepResult{revision: m.quota.revision, step: *m.quotaStepPending, window: m.quotaStepWindow, targets: m.quotaCandidates()[:1]}) + m = next.(Model) + case "threshold": + m.snapshot.RateLimits.Secondary.UsedPercent = 96 + m.evaluateQuotaStep(m.snapshot) + case "window": + reset := *m.snapshot.RateLimits.Secondary.ResetsAt + 3600 + m.snapshot.RateLimits.Secondary.ResetsAt = &reset + m.evaluateQuotaStep(m.snapshot) + case "matched": + next, _ := m.Update(quotaScanResult{revision: m.quota.revision, step: *m.quotaStepPending, matched: []string{"one"}}) + m = next.(Model) + } + if m.monitorContextRows["one"].review != "" { + t.Fatal("review choice survived resolution") + } + }) + } +} + +func TestProfilePillPreservesComposer(t *testing.T) { + for _, busy := range []bool{false, true} { + m, _ := profileTestModel(t) + m.monitorPrompt.input = newMonitorEditor() + m.monitorPrompt.input.SetValue("keep this draft") + m.monitorPrompt.session = "one" + m.monitorPrompt.busy = busy + if !busy { + m.monitorPrompt.input.Focus() + } + m.openMonitorAttention("attention-profile:two") + if m.monitorPrompt.session != "one" || m.monitorPrompt.input.Value() != "keep this draft" || m.monitorPrompt.busy != busy || m.monitorContextDetail != "one" { + t.Fatal("profile pill discarded composer") + } + } +} diff --git a/internal/ui/quota_step_down.go b/internal/ui/quota_step_down.go index 2ad7988..a0b848b 100644 --- a/internal/ui/quota_step_down.go +++ b/internal/ui/quota_step_down.go @@ -3,6 +3,7 @@ package ui import ( "context" "fmt" + "maps" "reflect" "time" @@ -100,17 +101,29 @@ func (m *Model) clearQuotaConfirmation() { m.quota.confirm = nil m.quotaStepConfirmUntil = time.Time{} } + +func (m *Model) clearQuotaReviewChoice(id string) { + m.monitorContextRows = maps.Clone(m.monitorContextRows) + for key, row := range m.monitorContextRows { + if id == "" || key == id { + row.review = "" + m.monitorContextRows[key] = row + } + } +} func (m *Model) evaluateQuotaStep(snapshot codex.Snapshot) tea.Cmd { if len(m.quotaSteps) == 0 { return nil } meter, window, ok := quotaPolicyWindow(snapshot) if !ok { + m.clearQuotaReviewChoice("") m.clearQuotaConfirmation() m.quotaStepPending = nil return nil } if m.quotaStepWindow != "" && m.quotaStepWindow != window { + m.clearQuotaReviewChoice("") if m.quota.cancel != nil { m.quota.cancel() } @@ -134,6 +147,7 @@ func (m *Model) evaluateQuotaStep(snapshot codex.Snapshot) tea.Cmd { } if !reflect.DeepEqual(candidate, m.quotaStepPending) { m.clearQuotaConfirmation() + m.clearQuotaReviewChoice("") } m.quotaStepPending = candidate if candidate == nil { @@ -241,6 +255,7 @@ func (m *Model) declineQuotaSession(id string) { m.quota.handled = map[string]int{} } m.quota.handled[id] = m.quotaStepPending.Threshold + m.clearQuotaReviewChoice(id) m.clearQuotaConfirmation() m.setQuotaSessionNotice(id, i18n.Text("Session skipped for this threshold.")) return From 82d1e401006b9fc9d100b718acbe652fbc717390 Mon Sep 17 00:00:00 2001 From: merefield Date: Fri, 18 Sep 2026 11:18:41 +0100 Subject: [PATCH 4/9] FEAT: add optional automatic quota profile policies --- README.md | 31 ++++++++++-- internal/codex/quota_step.go | 2 + internal/ui/model.go | 10 +++- internal/ui/monitor_profile.go | 2 +- internal/ui/quota_auto_test.go | 92 ++++++++++++++++++++++++++++++++++ internal/ui/quota_step_down.go | 29 +++++++++-- intro-post.md | 8 +-- main.go | 22 +++++--- main_test.go | 28 +++++++++++ 9 files changed, 204 insertions(+), 20 deletions(-) create mode 100644 internal/ui/quota_auto_test.go diff --git a/README.md b/README.md index 1aa93bf..08ef17e 100644 --- a/README.md +++ b/README.md @@ -2045,7 +2045,7 @@ deterministic PASS/FAIL verifier. --web-control opt into browser session approvals/prompts (requires --web) --web-port PORT local browser port (default: 0/automatic; requires --web) --refresh DURATION refresh interval (default: 1m) ---quota-step-down PERCENT:MODEL:EFFORT[:SPEED] offer a confirmed session profile at a quota threshold (repeatable) +--quota-step-down PERCENT:MODEL:EFFORT[:SPEED[:ask|auto]] set a session profile at a quota threshold (repeatable; default ask) --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 @@ -2071,7 +2071,7 @@ codexometer \ ### Quota step-down profiles -`--quota-step-down PERCENT:MODEL:EFFORT[:SPEED]` configures an opt-in profile +`--quota-step-down PERCENT:MODEL:EFFORT[:SPEED[:ask|auto]]` configures an opt-in profile for the longest ordinary Codex quota window (normally the weekly window). The flag is repeatable and thresholds must be unique. `SPEED` is optional and may be `fast`, `standard`, `slow`, `priority` or `flex`. Names such as `fast` and @@ -2081,7 +2081,28 @@ 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. -At a reached threshold, eligible sessions in the terminal **Sessions** tab get a +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 +subsequent turns without a quota approval pill. A running turn is not interrupted. +For example, mix automatic and reviewed thresholds: + +```sh +codexometer \ + --quota-step-down 80:gpt-5.6-sol:medium:standard:auto \ + --quota-step-down 95:gpt-5.6-luna:low::ask +``` + +An empty speed field (`::auto` or `::ask`) preserves the current speed. Automatic +updates are attempted once per session at the active threshold, with fresh quota +and session checks. New sessions follow the same policy. Already-matching +sessions are considered handled; subsequent manual changes are left alone. +Rejected or unverified updates remain visible in session detail and are not +automatically retried. A later threshold can apply its own profile. Tracking is +in memory and resets when Codexometer restarts or the quota window changes; +restarting with `auto` grants fresh consent to apply the active policy. + +In `ask` mode, at a reached threshold, eligible sessions in the terminal **Sessions** tab get a **QUOTA THRESHOLD** attention pill. Click it to review that session's current and proposed model, reasoning and speed in its detail pane. Controls also appear in split/wide detail when the entire review fits; otherwise open full detail or @@ -2112,8 +2133,8 @@ On every launch and refresh, Codexometer reads current session settings and compares them with the eligible target profile. Sessions already at that target need no approval or update, even after restarting with the same switches. Speed names are resolved through the model catalogue before comparison; omitted speed -accepts any current speed, while `standard` requires an unset explicit tier. -Other sessions still require approval. Skips and attempted-change tracking are +accepts any current speed, while `standard` accepts an unset tier or explicit `default`. +Other sessions require approval in `ask` mode. Skips and attempted-change tracking are process-local; approval itself is never persisted. Refreshes do not reapply a profile or overwrite manual changes. Shutdown cancels/drains outstanding work without sending any restoration calls; an already queued change may still apply. diff --git a/internal/codex/quota_step.go b/internal/codex/quota_step.go index 36dbc5a..75b62fa 100644 --- a/internal/codex/quota_step.go +++ b/internal/codex/quota_step.go @@ -12,6 +12,8 @@ type QuotaStep struct { Effort string // An advertised tier name or ID; empty preserves speed. ServiceTier string + // Empty and "ask" require review; "auto" grants launch-time consent. + Mode string } // ErrQuotaProfileUnverified means the server acknowledged the write but its diff --git a/internal/ui/model.go b/internal/ui/model.go index 8072ab9..bc47d52 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -524,11 +524,17 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { for _, id := range message.matched { delete(m.quota.notices, id) m.clearQuotaReviewChoice(id) + if message.step.Mode == "auto" { + if m.quota.handled == nil { + m.quota.handled = map[string]int{} + } + m.quota.handled[id] = message.step.Threshold + } } if message.err != nil { m.quota.scanError = i18n.Format("Session check failed: %s", message.err.Error()) } - return m, nil + return m.applyNextAutoQuotaSession() case quotaStepResult: if message.revision != m.quota.revision { return m, nil @@ -563,7 +569,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.setQuotaSessionNotice(target.ID, notice) } } - if message.err != nil { + if message.err != nil || message.step.Mode == "auto" { return m, m.evaluateQuotaStep(m.snapshot) } return m, nil diff --git a/internal/ui/monitor_profile.go b/internal/ui/monitor_profile.go index 1a47a46..07f6105 100644 --- a/internal/ui/monitor_profile.go +++ b/internal/ui/monitor_profile.go @@ -14,7 +14,7 @@ import ( // Eligibility is independent of the detail being viewed: one session may have // a Codex request and a separate quota review at the same time. func (m Model) quotaSessionCandidate(s monitorSession) (codex.QuotaSession, bool) { - if m.quotaStepPending == nil { + if m.quotaStepPending == nil || m.quotaStepPending.Mode == "auto" { return codex.QuotaSession{}, false } if threshold, handled := m.quota.handled[s.id]; handled && threshold >= m.quotaStepPending.Threshold { diff --git a/internal/ui/quota_auto_test.go b/internal/ui/quota_auto_test.go new file mode 100644 index 0000000..24e7889 --- /dev/null +++ b/internal/ui/quota_auto_test.go @@ -0,0 +1,92 @@ +package ui + +import ( + tea "charm.land/bubbletea/v2" + "errors" + "github.com/merefield/codexometer/internal/codex" + "testing" + "time" +) + +func drainQuotaCommands(t *testing.T, m Model, cmd tea.Cmd) Model { + t.Helper() + for i := 0; cmd != nil; i++ { + if i > 20 { + t.Fatal("quota command loop") + } + next, more := m.Update(cmd()) + m = next.(Model) + cmd = more + } + return m +} + +func TestAutoQuotaAppliesOnceAndDiscoversNewSessions(t *testing.T) { + m, f := profileTestModel(t) + m.quotaSteps[0].Mode = "auto" + m = drainQuotaCommands(t, m, m.evaluateQuotaStep(m.snapshot)) + if f.updates != 2 || len(m.monitorAttentionSessions()) != 0 { + t.Fatal("auto did not apply per session or showed approval") + } + f.sessions[0].Model = "manual-choice" + f.sessions = append(f.sessions, codex.QuotaSession{ID: "new", Model: "large", Effort: "high"}) + m = drainQuotaCommands(t, m, m.evaluateQuotaStep(m.snapshot)) + if f.updates != 3 || f.sessions[0].Model != "manual-choice" || f.sessions[2].Model != "small" { + t.Fatal("manual override lost or new session ignored") + } + m.snapshot.RateLimits.Secondary.UsedPercent = 96 + m = drainQuotaCommands(t, m, m.evaluateQuotaStep(m.snapshot)) + if f.updates != 3 || len(m.quotaCandidates()) != 3 { + t.Fatal("next ask threshold applied automatically") + } +} + +func TestAutoQuotaMatchingAndStaleInventory(t *testing.T) { + for _, stale := range []bool{false, true} { + m, f := profileTestModel(t) + m.quotaSteps[0].Mode = "auto" + f.sessions[0].Model = "small" + f.sessions[0].Effort = "medium" + if stale { + m.snapshot.FetchedAt = time.Now().Add(-time.Hour) + } + m = drainQuotaCommands(t, m, m.evaluateQuotaStep(m.snapshot)) + if stale { + if f.updates != 0 { + t.Fatal("stale quota caused write") + } + continue + } + if f.updates != 1 { + t.Fatal("matching session updated") + } + f.sessions[0].Model = "manual" + m = drainQuotaCommands(t, m, m.evaluateQuotaStep(m.snapshot)) + if f.updates != 1 { + t.Fatal("matching session not considered handled") + } + } +} + +func TestAutoQuotaFailureDoesNotRetryAndContinues(t *testing.T) { + m, f := profileTestModel(t) + m.quotaSteps[0].Mode = "auto" + scan := m.evaluateQuotaStep(m.snapshot) + next, apply := m.Update(scan()) + m = next.(Model) + if apply == nil { + t.Fatal("no automatic attempt") + } + // A rejected or uncertain attempt leaves the original session unchanged. + result := quotaStepResult{revision: m.quota.revision, step: *m.quotaStepPending, window: m.quotaStepWindow, targets: []codex.QuotaSession{f.sessions[0]}, err: errors.New("rejected")} + next, scan = m.Update(result) + m = next.(Model) + m = drainQuotaCommands(t, m, scan) + if f.updates != 1 || f.targets[0].ID != "two" || m.quota.notices["one"] == "" { + t.Fatal("failure retried or blocked next session") + } + m = drainQuotaCommands(t, m, m.evaluateQuotaStep(m.snapshot)) + if f.updates != 1 { + t.Fatal("refresh retried failure") + } +} diff --git a/internal/ui/quota_step_down.go b/internal/ui/quota_step_down.go index a0b848b..9246a5e 100644 --- a/internal/ui/quota_step_down.go +++ b/internal/ui/quota_step_down.go @@ -224,9 +224,20 @@ func (m Model) pressQuotaSession(id string, confirm bool) (Model, tea.Cmd) { m.clearQuotaConfirmation() return m, nil } - m.quota.busySession = id - step := m.quota.confirmStep - window := m.quota.confirmWindow + return m.startQuotaUpdate(targets) +} + +// Both reviewed and launch-authorized updates share the same backend checks. +func (m Model) startQuotaUpdate(targets []codex.QuotaSession) (Model, tea.Cmd) { + m.quota.busySession = targets[0].ID + step := *m.quotaStepPending + window := m.quotaStepWindow + if step.Mode == "auto" { + if m.quota.handled == nil { + m.quota.handled = map[string]int{} + } + m.quota.handled[targets[0].ID] = step.Threshold + } m.clearQuotaConfirmation() m.quotaStepBusy = true m.quota.revision++ @@ -243,6 +254,18 @@ func (m Model) pressQuotaSession(id string, confirm bool) (Model, tea.Cmd) { return quotaStepResult{revision: revision, window: window, step: step, targets: targets, updated: n, err: err} } } + +func (m Model) applyNextAutoQuotaSession() (Model, tea.Cmd) { + if m.quotaStepPending == nil || m.quotaStepPending.Mode != "auto" || + m.quotaStepBusy || m.quota.scanError != "" || !m.quotaFresh() { + return m, nil + } + targets := m.quotaCandidates() + if len(targets) == 0 { + return m, nil + } + return m.startQuotaUpdate(targets[:1]) +} func (m *Model) declineQuotaSession(id string) { if m.quotaStepBusy { return diff --git a/intro-post.md b/intro-post.md index 739cad0..0b4d7ad 100644 --- a/intro-post.md +++ b/intro-post.md @@ -49,13 +49,15 @@ the summary disappears before the attention strip so text and action controls keep priority. These shortcuts navigate only; they never approve or send for you. Optional quota step-down profiles also join the terminal session command centre: -configure `--quota-step-down PERCENT:MODEL:EFFORT[:SPEED]`, then follow a +configure `--quota-step-down PERCENT:MODEL:EFFORT[:SPEED[:ask|auto]]`, then follow a **QUOTA THRESHOLD** pill to review the proposed settings for that session. Apply and confirm individually, or skip. A session can have separate pills for its Codex request and quota review; each opens the corresponding detail, with Codex requests ordered first. Completion pills wait until outstanding reviews -are handled. Nothing changes automatically, and approved settings are not -rolled back when Codexometer closes. +are handled. The default `ask` mode requires approval; opt into `auto` per +threshold to apply the profile once per session for subsequent turns without +prompting. Already-matching sessions are left alone and failed updates are not +automatically retried. Applied settings are not rolled back when Codexometer closes. Start with the session that needs you. A **WORKING** badge in its telemetry box blinks only its ball, keeping the text steady; completion and attention states take priority. Recent activity alone is not treated as proof of work. Compact and wide detail panels keep content headings such as **LAST REPLY** or **APPROVAL REQUEST**, while full detail puts the session status in its box title without repeating it in the body. diff --git a/main.go b/main.go index 3d66b9e..53ead9f 100644 --- a/main.go +++ b/main.go @@ -67,9 +67,12 @@ func (f *quotaStepFlags) String() string { values := make([]string, 0, len(*f)) for _, step := range *f { value := fmt.Sprintf("%d:%s:%s", step.Threshold, step.Model, step.Effort) - if step.ServiceTier != "" { + if step.ServiceTier != "" || step.Mode != "" { value += ":" + quotaStepSpeedFlag(step.ServiceTier) } + if step.Mode != "" { + value += ":" + step.Mode + } values = append(values, value) } return strings.Join(values, ",") @@ -77,8 +80,8 @@ func (f *quotaStepFlags) String() string { func (f *quotaStepFlags) Set(value string) error { parts := strings.Split(value, ":") - if len(parts) != 3 && len(parts) != 4 { - return fmt.Errorf("must be PERCENT:MODEL:EFFORT[:SPEED]") + if len(parts) < 3 || len(parts) > 5 { + return fmt.Errorf("must be PERCENT:MODEL:EFFORT[:SPEED[:ask|auto]]") } threshold, err := strconv.Atoi(strings.TrimSpace(parts[0])) if err != nil || threshold < 1 || threshold > 100 { @@ -104,7 +107,7 @@ func (f *quotaStepFlags) Set(value string) error { } } serviceTier := "" - if len(parts) == 4 { + if len(parts) >= 4 && !(len(parts) == 5 && strings.TrimSpace(parts[3]) == "") { switch strings.ToLower(strings.TrimSpace(parts[3])) { case "fast", "slow", "flex", "priority": serviceTier = strings.ToLower(strings.TrimSpace(parts[3])) @@ -114,7 +117,14 @@ func (f *quotaStepFlags) Set(value string) error { return fmt.Errorf("speed must be fast, slow, flex, priority, or standard (and advertised by Codex)") } } - *f = append(*f, codex.QuotaStep{Threshold: threshold, Model: model, Effort: effort, ServiceTier: serviceTier}) + mode := "" + if len(parts) == 5 { + mode = strings.ToLower(strings.TrimSpace(parts[4])) + if mode != "ask" && mode != "auto" { + return fmt.Errorf("mode must be ask or auto") + } + } + *f = append(*f, codex.QuotaStep{Threshold: threshold, Model: model, Effort: effort, ServiceTier: serviceTier, Mode: mode}) sort.Slice(*f, func(i, j int) bool { return (*f)[i].Threshold < (*f)[j].Threshold }) return nil } @@ -386,7 +396,7 @@ func run(args []string, stdout, stderr io.Writer, deps dependencies) int { digBenchTimeout = flags.Duration("digbench-timeout", codex.DefaultDigBenchTimeout, "hard limit for --digbench-game") printVersion bool ) - flags.Var("aSteps, "quota-step-down", "offer PERCENT:MODEL:EFFORT[:SPEED] per session (repeatable; SPEED is advertised fast/slow/priority/flex, or standard)") + flags.Var("aSteps, "quota-step-down", "set PERCENT:MODEL:EFFORT[:SPEED[:ask|auto]] per session (repeatable; default ask; empty SPEED preserves speed)") flags.BoolVar(&printVersion, "version", false, "print the version and exit") flags.BoolVar(&printVersion, "v", false, "print the version and exit") if err := flags.Parse(args); err != nil { diff --git a/main_test.go b/main_test.go index 3d5ed11..3189515 100644 --- a/main_test.go +++ b/main_test.go @@ -97,6 +97,34 @@ func TestQuotaStepFlags(t *testing.T) { } } +func TestQuotaStepModes(t *testing.T) { + for _, tc := range []struct{ value, mode, speed string }{ + {"80:model:medium", "", ""}, + {"80:model:medium:fast", "", "fast"}, + {"80:model:medium::ask", "ask", ""}, + {"80:model:medium::auto", "auto", ""}, + {"80:model:medium:standard:auto", "auto", "default"}, + } { + var flags quotaStepFlags + if err := flags.Set(tc.value); err != nil { + t.Fatal(err) + } + if flags[0].Mode != tc.mode || flags[0].ServiceTier != tc.speed { + t.Fatalf("wrong policy: %#v", flags) + } + var roundtrip quotaStepFlags + if err := roundtrip.Set(flags.String()); err != nil || roundtrip[0] != flags[0] { + t.Fatalf("round trip: %s %v", flags.String(), err) + } + } + for _, value := range []string{"80:model:medium::skip", "80:model:medium:fast:", "80:model:medium:auto", "80:model:medium::auto:ask"} { + var flags quotaStepFlags + if flags.Set(value) == nil { + t.Fatalf("accepted %s", value) + } + } +} + func TestRunAuthCheckSuccessAndFailure(t *testing.T) { var stdout, stderr bytes.Buffer deps := dependencies{ From 87bc2bda8a654a112066afae054701f78b5ff30a Mon Sep 17 00:00:00 2001 From: merefield Date: Fri, 18 Sep 2026 11:42:27 +0100 Subject: [PATCH 5/9] FEAT: share quota profile policies with writable web sessions --- README.md | 29 +++- internal/codex/quota_profiles.go | 178 +++++++++++++++++++++ internal/codex/quota_profiles_test.go | 75 +++++++++ internal/ui/quota_step_down.go | 71 +++----- internal/web/control.go | 34 +++- internal/web/dist/assets/index-BHbhjimv.js | 25 +++ internal/web/dist/assets/index-CK6j1LaO.js | 23 --- internal/web/dist/index.html | 2 +- internal/web/profiles.go | 174 ++++++++++++++++++++ internal/web/profiles_test.go | 169 +++++++++++++++++++ internal/web/server.go | 6 +- internal/web/state.go | 26 +-- intro-post.md | 5 +- main.go | 8 +- main_test.go | 9 ++ web/src/SessionActions.svelte | 62 +++++-- web/src/Sessions.svelte | 47 +++++- web/src/state.svelte.ts | 9 ++ web/tests/browser.spec.ts | 55 ++++++- 19 files changed, 888 insertions(+), 119 deletions(-) create mode 100644 internal/codex/quota_profiles.go create mode 100644 internal/codex/quota_profiles_test.go create mode 100644 internal/web/dist/assets/index-BHbhjimv.js delete mode 100644 internal/web/dist/assets/index-CK6j1LaO.js create mode 100644 internal/web/profiles.go create mode 100644 internal/web/profiles_test.go diff --git a/README.md b/README.md index 08ef17e..44403d4 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,9 @@ unchanged by default. An opt-in [experimental browser interface](#experimental-browser-interface) provides read-only Quota, Sessions and Usage views with `codexometer --web`. -Add `--web-control` explicitly to enable supported session approvals and prompts -from full-page session detail; other browser views remain read-only. +Add `--web-control` explicitly to enable supported session approvals, prompts +and configured quota-profile reviews from full-page session detail. Optional +`auto` quota policies also require write mode; other browser views remain read-only. The terminal remains the default and the full-featured command centre. ## Why use it? @@ -2119,7 +2120,15 @@ Codex requests appear first; clicking either pill opens that specific review with only its own controls. Switching reviews cancels pending confirmation. Completion pills are suppressed while a quota review remains outstanding and return once it is handled, if the session is still complete. -These profile reviews are terminal-only; the browser interface is unchanged. +The same profile policy controller serves the terminal and writable browser +interfaces. For browser reviews, launch with `--web --web-control` plus the +quota flags. **QUOTA THRESHOLD** pills open the corresponding session's full +detail, alongside any native Codex approval or composer. Select **APPLY PROFILE** +or **SKIP**, then review and confirm. Confirmations bind to the exact session, +current settings, threshold and quota window and expire after 30 seconds. +Read-only `--web` rejects quota-policy flags and exposes no write endpoints. +An explicitly configured `auto` policy runs in the server even if no browser +page is open; it uses the same per-session attempt tracking as the terminal. The shared app-server's experimental `thread/settings/update` changes subsequent turns, not a turn already in progress. A queued acknowledgement alone is not reported as a verified change. Changed settings since review are skipped. @@ -2153,7 +2162,7 @@ Keep the terminal experience, or opt into a local Svelte browser dashboard: > and paths. Use an updated, dedicated browser profile without extensions, keep > the pairing link private, and never expose the server through a tunnel or public > port. Enabling `--web-control` additionally permits session prompts and approval -> decisions. Read [Browser security and local access](#browser-security-and-local-access) +> decisions and configured quota-profile changes. Read [Browser security and local access](#browser-security-and-local-access) > before enabling web mode; local-only access does not mean zero risk. ```sh @@ -2297,6 +2306,9 @@ codexometer --web --web-control # Safely try the simulated approval; no command runs codexometer --web --web-control --demo + +# Optional: automatically change subsequent-turn profiles at a quota threshold +codexometer --web --web-control --quota-step-down 80:gpt-5.6-sol:medium:standard:auto ``` The header and launching terminal clearly identify **SESSION CONTROL** mode. @@ -2340,7 +2352,8 @@ never saved in display preferences. Secret question answers use masked inputs. Go retains at most one prepared action per server for up to 30 seconds; a new preparation replaces the previous one. Raw Codex request tokens stay in Go; the browser receives opaque web offer/confirmation identifiers, not reusable -Codex credentials. No actions run merely by opening a page or reconnecting. +Codex credentials. No actions run merely by opening a page or reconnecting; +explicit launch-time `auto` quota policies run independently of browser navigation. ### Browser security and local access @@ -2445,8 +2458,10 @@ then runs browser tests against the production Go server, including its CSP. Frontend tests use simulated data only. Never point test traces or screenshots at real private sessions. -The terminal UI and Codex reader implementation are unchanged by this initial -web layer. Existing regression tests cover English presentation across themes +Quota-profile selection, matching, attempt tracking and verified application +live in a shared Go controller used by both presentations. Browser authentication +and confirmations remain in the web adapter; terminal layout and interactions +remain in the TUI. Existing regression tests cover English presentation across themes and sizes, localisation, responsive layouts, mouse hit regions, session navigation, approvals, reset confirmation and quota learning. Additional launch tests ensure `--web` cannot start the terminal or benchmark discovery and normal diff --git a/internal/codex/quota_profiles.go b/internal/codex/quota_profiles.go new file mode 100644 index 0000000..75015aa --- /dev/null +++ b/internal/codex/quota_profiles.go @@ -0,0 +1,178 @@ +package codex + +import ( + "context" + "errors" + "fmt" + "sync" + "time" +) + +// 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 { + mu sync.Mutex + client SessionSettingsClient + window string + handled map[string]int + outcomes map[string]error +} + +type QuotaProfileInventory struct { + Step QuotaStep + Resolved QuotaStep + Window string + Sessions []QuotaSession + Matched []string + Handled map[string]int + Outcomes map[string]error +} + +func NewQuotaProfiles(client SessionSettingsClient) *QuotaProfiles { + return &QuotaProfiles{client: client} +} + +// CaptureQuotaProfile freezes only the quota data used by a queued UI command. +func CaptureQuotaProfile(s Snapshot) Snapshot { + meter, _, ok := QuotaPolicyWindow(s) + if !ok { + return Snapshot{} + } + w := meter.Window + if w.ResetsAt != nil { + value := *w.ResetsAt + w.ResetsAt = &value + } + if w.WindowDurationMins != nil { + value := *w.WindowDurationMins + w.WindowDurationMins = &value + } + return Snapshot{FetchedAt: s.FetchedAt, AccountFingerprint: s.AccountFingerprint, RateLimitsByLimitID: map[string]RateLimitSnapshot{"codex": {Secondary: &w}}} +} + +// QuotaPolicyWindow identifies the longest ordinary Codex window and account. +func QuotaPolicyWindow(s Snapshot) (Meter, string, bool) { + var selected Meter + found := false + duration := func(m Meter) int64 { + if m.Window.WindowDurationMins == nil { + return 0 + } + return *m.Window.WindowDurationMins + } + for _, m := range s.Meters() { + if m.Kind == MeterQuotaWindow && m.LimitID == "codex" && (!found || duration(m) > duration(selected)) { + selected = m + found = true + } + } + if !found || selected.Window.ResetsAt == nil || s.AccountFingerprint == "" { + return selected, "", false + } + return selected, fmt.Sprintf("%s:%d:%d", s.AccountFingerprint, duration(selected), *selected.Window.ResetsAt), true +} + +func SelectQuotaProfile(s Snapshot, steps []QuotaStep) (QuotaStep, string, bool) { + meter, window, ok := QuotaPolicyWindow(s) + if !ok { + return QuotaStep{}, "", false + } + var selected QuotaStep + found := false + for _, step := range steps { + if step.Threshold <= meter.Window.UsedPercent && (!found || step.Threshold > selected.Threshold) { + selected = step + found = true + } + } + return selected, window, found +} + +func quotaProfileFresh(s Snapshot, refresh time.Duration) bool { + meter, _, ok := QuotaPolicyWindow(s) + age := time.Since(s.FetchedAt) + return ok && *meter.Window.ResetsAt > time.Now().Unix() && !s.FetchedAt.IsZero() && age >= 0 && age <= 2*refresh +} + +func (p *QuotaProfiles) setWindow(window string) { + if p.window != window { + p.window = window + p.handled = map[string]int{} + p.outcomes = map[string]error{} + } +} + +func (p *QuotaProfiles) Inventory(ctx context.Context, s Snapshot, steps []QuotaStep, refresh time.Duration) (QuotaProfileInventory, error) { + p.mu.Lock() + defer p.mu.Unlock() + step, window, ok := SelectQuotaProfile(s, steps) + out := QuotaProfileInventory{Step: step, Window: window, Handled: map[string]int{}, Outcomes: map[string]error{}} + if !quotaProfileFresh(s, refresh) { + return out, errors.New("quota is unavailable or stale") + } + p.setWindow(window) + if !ok { + return out, nil + } + resolved, err := p.client.ResolveQuotaStep(ctx, step) + if err != nil { + return out, err + } + out.Resolved = resolved + sessions, err := p.client.QuotaSessions(ctx) + if err != nil { + return out, err + } + for _, session := range sessions { + if session.MatchesQuotaStep(resolved) { + out.Matched = append(out.Matched, session.ID) + delete(p.outcomes, session.ID) + if step.Mode == "auto" { + p.handled[session.ID] = max(p.handled[session.ID], step.Threshold) + } + } else { + out.Sessions = append(out.Sessions, session) + } + } + for id, threshold := range p.handled { + out.Handled[id] = threshold + } + for id, err := range p.outcomes { + out.Outcomes[id] = err + } + return out, nil +} + +// Apply binds to the observed quota window, active step and exact target. An +// attempt is consumed before IO, including on errors; no automatic retries. +func (p *QuotaProfiles) Apply(ctx context.Context, s Snapshot, steps []QuotaStep, refresh time.Duration, target QuotaSession, step QuotaStep, window string, skip bool) (int, error) { + p.mu.Lock() + defer p.mu.Unlock() + current, currentWindow, ok := SelectQuotaProfile(s, steps) + if !ok || current != step || currentWindow != window || !quotaProfileFresh(s, refresh) { + return 0, errors.New("quota profile changed or is stale") + } + p.setWindow(window) + if threshold, done := p.handled[target.ID]; done && threshold >= step.Threshold { + return 0, errors.New("session already handled for this threshold") + } + p.handled[target.ID] = step.Threshold + if skip { + return 0, nil + } + n, err := p.client.ApplyQuotaProfile(ctx, []QuotaSession{target}, step) + if err != nil { + p.outcomes[target.ID] = err + } else { + delete(p.outcomes, target.ID) + } + return n, err +} + +// Skip is presentation-local consent bookkeeping and never performs IO. +func (p *QuotaProfiles) Skip(id string, threshold int, window string) { + p.mu.Lock() + defer p.mu.Unlock() + p.setWindow(window) + p.handled[id] = max(p.handled[id], threshold) +} diff --git a/internal/codex/quota_profiles_test.go b/internal/codex/quota_profiles_test.go new file mode 100644 index 0000000..c65d7e4 --- /dev/null +++ b/internal/codex/quota_profiles_test.go @@ -0,0 +1,75 @@ +package codex + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +type policyTestClient struct{ writes int } + +func (c *policyTestClient) QuotaSessions(context.Context) ([]QuotaSession, error) { + return []QuotaSession{{ID: "one", Model: "large", Effort: "high"}}, nil +} +func (c *policyTestClient) ResolveQuotaStep(_ context.Context, s QuotaStep) (QuotaStep, error) { + return s, nil +} +func (c *policyTestClient) ApplyQuotaProfile(context.Context, []QuotaSession, QuotaStep) (int, error) { + c.writes++ + return 0, ErrQuotaProfileUnverified +} +func (c *policyTestClient) CloseQuotaProfiles() {} + +func TestQuotaProfilesConcurrentAttemptsAndWindowReset(t *testing.T) { + client := &policyTestClient{} + p := NewQuotaProfiles(client) + s := DemoSnapshot() + s.AccountFingerprint = "account" + s.FetchedAt = time.Now() + s.RateLimits.Secondary.UsedPercent = 85 + steps := []QuotaStep{{Threshold: 80, Model: "small", Effort: "medium", Mode: "auto"}} + inventory, err := p.Inventory(context.Background(), s, steps, time.Minute) + if err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + for i := 0; i < 2; i++ { + wg.Go(func() { + p.Apply(context.Background(), s, steps, time.Minute, inventory.Sessions[0], inventory.Step, inventory.Window, false) + }) + } + wg.Wait() + if client.writes != 1 { + t.Fatal("concurrent presentations retried uncertain write") + } + current, err := p.Inventory(context.Background(), s, steps, time.Minute) + if err != nil || current.Handled["one"] != 80 || !errors.Is(current.Outcomes["one"], ErrQuotaProfileUnverified) { + t.Fatal("uncertain outcome lost") + } + reset := *s.RateLimits.Secondary.ResetsAt + 3600 + s.RateLimits.Secondary.ResetsAt = &reset + current, err = p.Inventory(context.Background(), s, steps, time.Minute) + if err != nil || len(current.Handled) != 0 || len(current.Outcomes) != 0 { + t.Fatal("new window retained old attempts") + } +} + +func TestQuotaProfileCaptureAndSelection(t *testing.T) { + s := DemoSnapshot() + s.AccountFingerprint = "account" + s.FetchedAt = time.Now() + s.RateLimits.Secondary.UsedPercent = 85 + frozen := CaptureQuotaProfile(s) + step, window, ok := SelectQuotaProfile(s, []QuotaStep{{Threshold: 95}, {Threshold: 80, Model: "target"}, {Threshold: 50}}) + if !ok || step.Threshold != 80 { + t.Fatal("wrong active policy") + } + s.RateLimits.Secondary.UsedPercent = 99 + *s.RateLimits.Secondary.ResetsAt += 3600 + old, oldWindow, ok := SelectQuotaProfile(frozen, []QuotaStep{{Threshold: 80, Model: "target"}, {Threshold: 95}}) + if !ok || old != step || oldWindow != window { + t.Fatal("queued capture changed with source snapshot") + } +} diff --git a/internal/ui/quota_step_down.go b/internal/ui/quota_step_down.go index 9246a5e..093dee7 100644 --- a/internal/ui/quota_step_down.go +++ b/internal/ui/quota_step_down.go @@ -13,6 +13,7 @@ import ( ) type quotaControl struct { + engine *codex.QuotaProfiles revision uint64 cancel context.CancelFunc sessions []codex.QuotaSession @@ -56,28 +57,8 @@ func quotaStepProfile(step codex.QuotaStep) string { } return result } -func windowMinutes(w codex.Window) int64 { - if w.WindowDurationMins == nil { - return 0 - } - return *w.WindowDurationMins -} func quotaPolicyWindow(s codex.Snapshot) (codex.Meter, string, bool) { - var selected codex.Meter - found := false - for _, m := range s.Meters() { - if m.Kind != codex.MeterQuotaWindow || m.LimitID != "codex" { - continue - } - if !found || windowMinutes(m.Window) > windowMinutes(selected.Window) { - selected = m - found = true - } - } - if !found || selected.Window.ResetsAt == nil || s.AccountFingerprint == "" { - return selected, "", false - } - return selected, fmt.Sprintf("%s:%d:%d", s.AccountFingerprint, windowMinutes(selected.Window), *selected.Window.ResetsAt), true + return codex.QuotaPolicyWindow(s) } func (m Model) quotaFresh() bool { meter, window, ok := quotaPolicyWindow(m.snapshot) @@ -85,11 +66,8 @@ func (m Model) quotaFresh() bool { return false } var candidate *codex.QuotaStep - for _, step := range m.quotaSteps { - if step.Threshold <= meter.Window.UsedPercent { - copy := step - candidate = © - } + if step, _, selected := codex.SelectQuotaProfile(m.snapshot, m.quotaSteps); selected { + candidate = &step } if !reflect.DeepEqual(candidate, m.quotaStepPending) { return false @@ -115,7 +93,7 @@ func (m *Model) evaluateQuotaStep(snapshot codex.Snapshot) tea.Cmd { if len(m.quotaSteps) == 0 { return nil } - meter, window, ok := quotaPolicyWindow(snapshot) + _, window, ok := quotaPolicyWindow(snapshot) if !ok { m.clearQuotaReviewChoice("") m.clearQuotaConfirmation() @@ -139,11 +117,8 @@ func (m *Model) evaluateQuotaStep(snapshot codex.Snapshot) tea.Cmd { } m.quotaStepWindow = window var candidate *codex.QuotaStep - for _, step := range m.quotaSteps { - if step.Threshold <= meter.Window.UsedPercent { - copy := step - candidate = © - } + if step, _, selected := codex.SelectQuotaProfile(snapshot, m.quotaSteps); selected { + candidate = &step } if !reflect.DeepEqual(candidate, m.quotaStepPending) { m.clearQuotaConfirmation() @@ -162,27 +137,23 @@ func (m *Model) evaluateQuotaStep(snapshot codex.Snapshot) tea.Cmd { step := *candidate ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) m.quota.cancel = cancel + if c, ok := m.fetcher.(codex.SessionSettingsClient); ok && m.quota.engine == nil { + m.quota.engine = codex.NewQuotaProfiles(c) + } + engine := m.quota.engine return func() tea.Msg { defer cancel() - c, ok := m.fetcher.(codex.SessionSettingsClient) - if !ok { + if engine == nil { return quotaScanResult{revision: revision, step: step, err: fmt.Errorf("%s", i18n.Text("Session controls unavailable."))} } - resolved, err := c.ResolveQuotaStep(ctx, step) - if err != nil { - return quotaScanResult{revision: revision, step: step, err: err} - } - sessions, err := c.QuotaSessions(ctx) + inventory, err := engine.Inventory(ctx, snapshot, m.quotaSteps, m.refreshEvery) var candidates []codex.QuotaSession - var matched []string - for _, session := range sessions { - if session.MatchesQuotaStep(resolved) { - matched = append(matched, session.ID) - } else { + for _, session := range inventory.Sessions { + if threshold, handled := inventory.Handled[session.ID]; !handled || threshold < step.Threshold { candidates = append(candidates, session) } } - return quotaScanResult{revision: revision, step: step, sessions: candidates, matched: matched, err: err} + return quotaScanResult{revision: revision, step: step, sessions: candidates, matched: inventory.Matched, err: err} } } func (m Model) quotaCandidates() []codex.QuotaSession { @@ -229,6 +200,7 @@ func (m Model) pressQuotaSession(id string, confirm bool) (Model, tea.Cmd) { // Both reviewed and launch-authorized updates share the same backend checks. func (m Model) startQuotaUpdate(targets []codex.QuotaSession) (Model, tea.Cmd) { + snapshot := codex.CaptureQuotaProfile(m.snapshot) m.quota.busySession = targets[0].ID step := *m.quotaStepPending window := m.quotaStepWindow @@ -250,7 +222,11 @@ func (m Model) startQuotaUpdate(targets []codex.QuotaSession) (Model, tea.Cmd) { if !ok { return quotaStepResult{revision: revision, window: window, step: step, targets: targets, err: fmt.Errorf("%s", i18n.Text("Session controls unavailable."))} } - n, err := c.ApplyQuotaProfile(ctx, targets, step) + engine := m.quota.engine + if engine == nil { + engine = codex.NewQuotaProfiles(c) + } + n, err := engine.Apply(ctx, snapshot, m.quotaSteps, m.refreshEvery, targets[0], step, window, false) return quotaStepResult{revision: revision, window: window, step: step, targets: targets, updated: n, err: err} } } @@ -278,6 +254,9 @@ func (m *Model) declineQuotaSession(id string) { m.quota.handled = map[string]int{} } m.quota.handled[id] = m.quotaStepPending.Threshold + if m.quota.engine != nil { + m.quota.engine.Skip(id, m.quotaStepPending.Threshold, m.quotaStepWindow) + } m.clearQuotaReviewChoice(id) m.clearQuotaConfirmation() m.setQuotaSessionNotice(id, i18n.Text("Session skipped for this threshold.")) diff --git a/internal/web/control.go b/internal/web/control.go index 697b648..a4551a3 100644 --- a/internal/web/control.go +++ b/internal/web/control.go @@ -22,6 +22,7 @@ import ( // binds the browser's selection to the exact source request without disclosing // Codex tokens. One pending confirmation bounds memory (including secret input). type control struct { + profiles *profileControl mu sync.Mutex store *store approvals codex.SessionApprovalClient @@ -45,6 +46,7 @@ type actionQuestion struct { } type actionOffer struct { + Profile *profileReview `json:"profile,omitempty"` ID string `json:"id"` Session string `json:"session"` Thread string `json:"thread"` @@ -56,6 +58,9 @@ type actionOffer struct { } type offeredAction struct { + profileTarget codex.QuotaSession + profileStep codex.QuotaStep + profileWindow string actionOffer token string decisions []string @@ -63,6 +68,7 @@ type offeredAction struct { } type actionRequest struct { + Review string `json:"review,omitempty"` Session string `json:"session"` Offer string `json:"offer"` Choice *int `json:"choice,omitempty"` @@ -80,6 +86,7 @@ func newControl(source Source, store *store) *control { c := &control{store: store, key: rand.Text()} c.approvals, _ = source.(codex.SessionApprovalClient) c.prompts, _ = source.(codex.SessionPromptClient) + c.profiles = newProfileControl(source) return c } @@ -152,6 +159,9 @@ func validAction(o offeredAction, r actionRequest) bool { if o.ID == "" || o.ID != r.Offer { return false } + if o.Kind == "profile" { + return len(r.Answers) == 0 && r.Choice != nil && (*r.Choice == 0 || *r.Choice == 1) + } if o.Kind == "approval" { return len(r.Answers) == 0 && r.Choice != nil && *r.Choice >= 0 && *r.Choice < len(o.decisions) } @@ -186,7 +196,7 @@ func (c *control) handle(action, origin string) http.HandlerFunc { var body actionRequest d := json.NewDecoder(r.Body) d.DisallowUnknownFields() - if d.Decode(&body) != nil || d.Decode(new(any)) != io.EOF || body.Session == "" || len(body.Session) > 512 { + if d.Decode(&body) != nil || d.Decode(new(any)) != io.EOF || body.Session == "" || len(body.Session) > 512 || (body.Review != "" && body.Review != "profile") { http.Error(w, "Invalid request", 400) return } @@ -198,14 +208,26 @@ func (c *control) handle(action, origin string) http.HandlerFunc { // Consume before any IO, even on failure. Never retry ambiguous writes. if action == "commit" { p := c.pending - if p == nil || p.id != body.Confirmation || p.request.Session != body.Session || p.request.Offer != body.Offer { + if p == nil || p.id != body.Confirmation || p.request.Session != body.Session || p.request.Offer != body.Offer || p.request.Review != body.Review { http.Error(w, errUnavailable.Error(), 409) return } c.pending = nil body = p.request // Only the exact server-prepared payload can be sent. } - o, err := c.offer(body.Session) + timeout := 3 * time.Second + if body.Review == "profile" { + timeout = 8 * time.Second + } + ctx, cancel := context.WithTimeout(r.Context(), timeout) + defer cancel() + var o offeredAction + var err error + if body.Review == "profile" { + o, err = c.profileOffer(ctx, body.Session) + } else { + o, err = c.offer(body.Session) + } if err != nil { http.Error(w, errUnavailable.Error(), 409) return @@ -237,9 +259,9 @@ func (c *control) handle(action, origin string) http.HandlerFunc { _ = json.NewEncoder(w).Encode(map[string]any{"confirmation": p.id, "expires": p.until}) return } - ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) - defer cancel() - if o.Kind == "approval" { + if o.Kind == "profile" { + err = c.commitProfile(ctx, o, *body.Choice) + } else if o.Kind == "approval" { err = c.approvals.RespondSessionApproval(ctx, o.token, o.decisions[*body.Choice]) } else { err = c.prompts.SendSessionPrompt(ctx, o.token, body.Answers) diff --git a/internal/web/dist/assets/index-BHbhjimv.js b/internal/web/dist/assets/index-BHbhjimv.js new file mode 100644 index 0000000..abbd4bc --- /dev/null +++ b/internal/web/dist/assets/index-BHbhjimv.js @@ -0,0 +1,25 @@ +(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){for(var t=0;t{e=n,t=r}),resolve:e,reject:t}}function h(e,t,n=!1){return e===void 0?n?t():t:e}function g(e,t){if(Array.isArray(e))return e;if(t===void 0||!(Symbol.iterator in e))return Array.from(e);let n=[];for(let r of e)if(n.push(r),n.length===t)break;return n}var _=1024,v=2048,y=4096,b=8192,x=16384,S=32768,C=1<<25,w=65536,T=1<<19,ee=1<<20,E=1<<25,te=65536,ne=1<<21,re=1<<22,ie=1<<23,ae=Symbol(`$state`),oe=Symbol(`component`),se=Symbol(`legacy props`),ce=Symbol(``),le=Symbol(`attributes`),ue=Symbol(`class`),de=Symbol(`style`),fe=Symbol(`text`),pe=Symbol(`form reset`),me=new class extends Error{name=`StaleReactionError`;message="The reaction that called `getAbortSignal()` was re-run or destroyed"},he=!!globalThis.document?.contentType&&globalThis.document.contentType.includes(`xml`),ge={},D=Symbol(`uninitialized`),_e=`http://www.w3.org/1999/xhtml`;function ve(){console.warn(`https://svelte.dev/e/derived_inert`)}function ye(e){console.warn(`https://svelte.dev/e/hydration_mismatch`)}function be(){console.warn(`https://svelte.dev/e/select_multiple_invalid_value`)}function xe(){console.warn(`https://svelte.dev/e/svelte_boundary_reset_noop`)}var O=!1;function Se(e){O=e}var k;function Ce(e){if(e===null)throw ye(),ge;return k=e}function we(){return Ce(ln(k))}function A(e){if(O){if(ln(k)!==null)throw ye(),ge;k=e}}function Te(e=1){if(O){for(var t=e,n=k;t--;)n=ln(n);k=n}}function Ee(e=!0){for(var t=0,n=k;;){if(n.nodeType===8){var r=n.data;if(r===`]`){if(t===0)return n;--t}else(r===`[`||r===`[!`||r[0]===`[`&&!isNaN(Number(r.slice(1))))&&(t+=1)}var i=ln(n);e&&n.remove(),n=i}}function De(e){if(!e||e.nodeType!==8)throw ye(),ge;return e.data}function Oe(e){return e===this.v}function ke(e,t){return e==e?e!==t||typeof e==`object`&&!!e||typeof e==`function`:t==t}function Ae(e){return!ke(e,this.v)}function je(e){throw Error(`https://svelte.dev/e/lifecycle_outside_component`)}function Me(){throw Error(`https://svelte.dev/e/async_derived_orphan`)}function Ne(e,t,n){throw Error(`https://svelte.dev/e/each_key_duplicate`)}function Pe(e){throw Error(`https://svelte.dev/e/effect_in_teardown`)}function Fe(){throw Error(`https://svelte.dev/e/effect_in_unowned_derived`)}function Ie(e){throw Error(`https://svelte.dev/e/effect_orphan`)}function Le(){throw Error(`https://svelte.dev/e/effect_update_depth_exceeded`)}function Re(e){throw Error(`https://svelte.dev/e/props_invalid_value`)}function ze(){throw Error(`https://svelte.dev/e/state_descriptors_fixed`)}function Be(){throw Error(`https://svelte.dev/e/state_prototype_fixed`)}function Ve(){throw Error(`https://svelte.dev/e/state_unsafe_mutation`)}function He(){throw Error(`https://svelte.dev/e/svelte_boundary_reset_onerror`)}var Ue=!1;function We(){Ue=!0}var j=null;function Ge(e){j=e}function Ke(e,t=!1,n){j={p:j,i:!1,c:null,e:null,s:e,x:null,r:U,l:Ue&&!t?{s:null,u:null,$:[]}:null}}function qe(e){var t=j,n=t.e;if(n!==null){t.e=null;for(var r of n)Sn(r)}return e!==void 0&&(t.x=e),t.i=!0,j=t.p,Je(e)}function Je(e={}){return i(e,oe,{value:!0}),e}function Ye(){return!Ue||j!==null&&j.l===null}var Xe=[];function Ze(){var e=Xe;Xe=[],p(e)}function Qe(e){if(Xe.length===0&&!Ot){var t=Xe;queueMicrotask(()=>{t===Xe&&Ze()})}Xe.push(e)}function $e(){for(;Xe.length>0;)Ze()}var et=~(v|y|_);function M(e,t){e.f=e.f&et|t}function tt(e){e.f&512||e.deps===null?M(e,_):M(e,y)}function nt(e){if(e!==null)for(let t of e)t.f&2&&t.f&65536&&(t.f^=te,nt(t.deps))}function rt(e,t,n){e.f&2048?t.add(e):e.f&4096&&n.add(e),nt(e.deps),M(e,_)}var it=!1;function at(e){var t=it;try{return it=!1,[e(),it]}finally{it=t}}function ot(e){O&&cn(e)!==null&&un(e)}var st=!1;function ct(){st||(st=!0,document.addEventListener(`reset`,e=>{Promise.resolve().then(()=>{if(!e.defaultPrevented)for(let t of e.target.elements)t[pe]?.()})},{capture:!0}))}function lt(e){var t=H,n=U;Gn(null),Kn(null);try{return e()}finally{Gn(t),Kn(n)}}function ut(e,t,n,r=n){e.addEventListener(t,()=>lt(n));let i=e[pe];e[pe]=i?()=>{i(),r(!0)}:()=>r(!0),ct()}function dt(e,t,n,r){let i=Ye()?ht:vt;var a=e.filter(e=>!e.settled),o=t.map(i);if(n.length===0&&a.length===0){r(o);return}var s=U,c=ft(),l=a.length===1?a[0].promise:a.length>1?Promise.all(a.map(e=>e.promise)):null;function u(e){if(!(s.f&16384)){c();try{r([...o,...e])}catch(e){hn(e,s)}pt()}}var d=mt();if(n.length===0){l.then(()=>u([])).finally(d);return}function f(){Promise.all(n.map(e=>_t(e))).then(u).catch(e=>hn(e,s)).finally(d)}l?l.then(()=>{c(),f(),pt()}):f()}function ft(){var e=U,t=H,n=j,r=P;return function(i=!0){Kn(e),Gn(t),Ge(n),i&&!(e.f&16384)&&(r?.activate(),r?.apply())}}function pt(e=!0){Kn(null),Gn(null),Ge(null),e&&P?.deactivate()}function mt(){var e=U,t=e.b,n=P,r=!!t?.is_rendered();return t?.update_pending_count(1,n),n.increment(r,e),()=>{t?.update_pending_count(-1,n),n.decrement(r,e)}}function ht(e){var t=2|v;return U!==null&&(U.f|=T),{ctx:j,deps:null,effects:null,equals:Oe,f:t,fn:e,reactions:null,rv:0,v:D,wv:0,parent:U,ac:null}}var gt=Symbol(`obsolete`);function _t(e,t,n){let r=U;r===null&&Me();var i=void 0,a=Gt(D),o=!H,s=new Set;return Tn(()=>{var t=U,n=m();i=n.promise;try{Promise.resolve(e()).then(n.resolve,e=>{e!==me&&n.reject(e)}).finally(pt)}catch(e){n.reject(e),pt()}var c=P;if(o){if(t.f&32768)var l=mt();if(r.b?.is_rendered())c.async_deriveds.get(t)?.reject(gt);else for(let e of s.values())e.reject(gt);s.add(n),c.async_deriveds.set(t,n)}let u=(e,t=void 0)=>{l?.(),s.delete(n),t!==gt&&(c.activate(),t?(a.f|=ie,qt(a,t)):(a.f&8388608&&(a.f^=ie),qt(a,e)),c.deactivate())};n.promise.then(u,e=>u(null,e||`unknown`))}),bn(()=>{for(let e of s)e.reject(gt)}),new Promise(e=>{function t(n){function r(){n===i?e(a):t(i)}n.then(r,r)}t(i)})}function N(e){let t=ht(e);return Jn(t),t}function vt(e){let t=ht(e);return t.equals=Ae,t}function yt(e){var t=e.effects;if(t!==null){e.effects=null;for(var n=0;n{t.ac.abort(me),t.ac=null}),t.fn!==null&&(t.teardown=f),lr(t,0),An(t))}function Ct(e){if(e.effects!==null)for(let t of e.effects)t.teardown&&t.fn!==null&&ur(t)}var wt=null,P=null,Tt=null,Et=null,Dt=null,Ot=!1,kt=!1,At=null,jt=null,Mt=0,Nt=1,Pt=class e{id=Nt++;#e=!1;linked=!0;#t=null;#n=null;async_deriveds=new Map;current=new Map;previous=new Map;#r=new Set;#i=new Set;#a=0;#o=new Map;#s=null;#c=[];#l=[];#u=new Set;#d=new Set;#f=new Map;#p=new Set;is_fork=!1;#m=!1;constructor(){wt===null?wt=this:(wt.#n=this,this.#t=wt),wt=this}#h(){if(this.is_fork)return!0;for(let n of this.#o.keys()){for(var e=n,t=!1;e.parent!==null;){if(this.#f.has(e)){t=!0;break}e=e.parent}if(!t)return!0}return!1}skip_effect(e){this.#f.has(e)||this.#f.set(e,{d:[],m:[]}),this.#p.delete(e)}unskip_effect(e,t=e=>this.schedule(e)){var n=this.#f.get(e);if(n){this.#f.delete(e);for(var r of n.d)M(r,v),t(r);for(r of n.m)M(r,y),t(r)}this.#p.add(e)}#g(){this.#e=!0,Mt++>1e3&&(this.#x(),It());for(let e of this.#u)this.#d.delete(e),M(e,v),this.schedule(e);for(let e of this.#d)M(e,y),this.schedule(e);let t=this.#c;this.#c=[],this.apply();var n=At=[],r=[],i=jt=[];for(let e of t)try{this.#_(e,n,r)}catch(t){throw Vt(e),this.#h()||this.discard(),t}if(P=null,i.length>0){var a=e.ensure();for(let e of i)a.schedule(e)}if(At=null,jt=null,this.#h()){this.#b(r),this.#b(n);for(let[e,t]of this.#f)Bt(e,t);i.length>0&&P.#g();return}let o=this.#v();if(o){this.#b(r),this.#b(n),o.#y(this);return}this.#u.clear(),this.#d.clear();for(let e of this.#r)e(this);this.#r.clear(),Tt=this,Rt(r),Rt(n),Tt=null,this.#s?.resolve();var s=P;if(this.#a===0&&(this.#c.length===0||s!==null)&&this.#x(),this.#c.length>0){if(s!==null){let e=s;e.#c.push(...this.#c.filter(t=>!e.#c.includes(t)))}else s=this}s!==null&&(Ut.clear(),s.#g())}#_(e,t,n){e.f^=_;for(var r=e.first;r!==null;){var i=r.f,a=!!(i&96);if(!(a&&i&1024||i&8192||this.#f.has(r))&&r.fn!==null){a?r.f^=_:i&4?t.push(r):ir(r)&&(i&16&&this.#d.add(r),ur(r));var o=r.first;if(o!==null){r=o;continue}}for(;r!==null;){var s=r.next;if(s!==null){r=s;break}r=r.parent}}}#v(){for(var e=this.#t;e!==null;){if(!e.is_fork){for(let[t,[,n]]of this.current)if(e.current.has(t)&&!n)return e}e=e.#t}return null}#y(e){for(let[t,n]of e.current)!this.previous.has(t)&&e.previous.has(t)&&this.previous.set(t,e.previous.get(t)),this.current.set(t,n);for(let[t,n]of e.async_deriveds){let e=this.async_deriveds.get(t);e&&n.promise.then(e.resolve).catch(e.reject)}e.async_deriveds.clear(),this.transfer_effects(e.#u,e.#d);let t=e=>{var n=e.reactions;if(n!==null&&!(e.f&2&&!(e.f&6144)))for(let e of n){var r=e.f;if(r&2)t(e);else{var i=e;r&4194320&&!this.async_deriveds.has(i)&&(this.#d.delete(i),M(i,v),this.schedule(i))}}};for(let e of this.current.keys())t(e);this.oncommit(()=>e.discard()),e.#x(),P=this,this.#g()}#b(e){for(var t=0;t{this.#m=!1,this.linked&&this.flush()}))}transfer_effects(e,t){for(let t of e)this.#u.add(t);for(let e of t)this.#d.add(e);e.clear(),t.clear()}oncommit(e){this.#r.add(e)}ondiscard(e){this.#i.add(e)}settled(){return(this.#s??=m()).promise}static ensure(){if(P===null){let t=P=new e;!kt&&!Ot&&Qe(()=>{t.#e||t.flush()})}return P}apply(){Et=null}schedule(e){if(Dt=e,e.b?.is_pending&&e.f&16777228&&!(e.f&32768)){e.b.defer_effect(e);return}for(var t=e;t.parent!==null;){t=t.parent;var n=t.f;if(At!==null&&t===U&&(H===null||!(H.f&2)))return;if(n&96){if(!(n&1024))return;t.f^=_}}this.#c.push(t)}#x(){if(this.linked){var e=this.#t,t=this.#n;e===null||(e.#n=t),t===null?wt=e:t.#t=e,this.linked=!1}}};function Ft(e){var t=Ot;Ot=!0;try{var n;for(e&&(P!==null&&!P.is_fork&&P.flush(),n=e());;){if($e(),P===null)return n;P.flush()}}finally{Ot=t}}function It(){try{Le()}catch(e){hn(e,Dt)}}var Lt=null;function Rt(e){var t=e.length;if(t!==0){for(var n=0;n0)){Ut.clear();for(let e of Lt){if(e.f&24576)continue;let t=[e],n=e.parent;for(;n!==null;)Lt.has(n)&&(Lt.delete(n),t.push(n)),n=n.parent;for(let e=t.length-1;e>=0;e--){let n=t[e];n.f&24576||ur(n)}}Lt.clear()}}Lt=null}}function zt(e){P.schedule(e)}function Bt(e,t){if(!(e.f&32&&e.f&1024)){e.f&2048?t.d.push(e):e.f&4096&&t.m.push(e),M(e,_);for(var n=e.first;n!==null;)Bt(n,t),n=n.next}}function Vt(e){M(e,_);for(var t=e.first;t!==null;)Vt(t),t=t.next}var Ht=new Set,Ut=new Map,Wt=!1;function Gt(e,t){return{f:0,v:e,reactions:null,equals:Oe,rv:0,wv:0}}function F(e,t){let n=Gt(e,t);return Jn(n),n}function Kt(e,t=!1,n=!0){let r=Gt(e);return t||(r.equals=Ae),Ue&&n&&j!==null&&j.l!==null&&(j.l.s??=[]).push(r),r}function I(e,t,n=!1){return H!==null&&(!Wn||H.f&131072)&&Ye()&&H.f&4325394&&(qn===null||!qn.has(e))&&Ve(),qt(e,n?Qt(t):t,jt)}function qt(e,t,n=null){if(!e.equals(t)){Hn?Ut.set(e,t):Ut.has(e)||Ut.set(e,e.v);var r=Pt.ensure();if(r.capture(e,t),e.f&2){let t=e;e.f&2048&&bt(t),Et===null&&tt(t)}e.wv=rr(),Zt(e,v,n),Ye()&&U!==null&&U.f&1024&&!(U.f&96)&&(Zn===null?Qn([e]):Zn.push(e)),!r.is_fork&&Ht.size>0&&!Wt&&Jt()}return t}function Jt(){Wt=!1;for(let e of Ht){e.f&1024&&M(e,y);let t;try{t=ir(e)}catch{t=!0}t&&ur(e)}Ht.clear()}function Yt(e,t=1){var n=W(e),r=t===1?n++:n--;return I(e,n),r}function Xt(e){I(e,e.v+1)}function Zt(e,t,n){var r=e.reactions;if(r!==null)for(var i=Ye(),a=r.length,o=0;o{if(tr===d)return e();var t=H,n=tr;Gn(null),nr(d);var r=e();return Gn(t),nr(n),r};return i&&r.set(`length`,F(t.length,u)),new Proxy(t,{defineProperty(e,t,n){(!(`value`in n)||n.configurable===!1||n.enumerable===!1||n.writable===!1)&&ze();var i=r.get(t);return i===void 0?f(()=>{var e=F(n.value,u);return r.set(t,e),e}):I(i,n.value,!0),!0},deleteProperty(e,t){var n=r.get(t);if(n===void 0){if(t in e){let e=f(()=>F(D,u));r.set(t,e),Xt(o)}}else I(n,D),Xt(o);return!0},get(e,n,i){if(n===ae)return t;var o=r.get(n),s=n in e;if(o===void 0&&(!s||a(e,n)?.writable)&&(o=f(()=>F(Qt(s?e[n]:D),u)),r.set(n,o)),o!==void 0){var c=W(o);return c===D?void 0:c}return Reflect.get(e,n,i)},getOwnPropertyDescriptor(e,t){var n=Reflect.getOwnPropertyDescriptor(e,t);if(n&&`value`in n){var i=r.get(t);i&&(n.value=W(i))}else if(n===void 0){var a=r.get(t),o=a?.v;if(a!==void 0&&o!==D)return{enumerable:!0,configurable:!0,value:o,writable:!0}}return n},has(e,t){if(t===ae)return!0;var n=r.get(t),i=n!==void 0&&n.v!==D||Reflect.has(e,t);return(n!==void 0||U!==null&&(!i||a(e,t)?.writable))&&(n===void 0&&(n=f(()=>F(i?Qt(e[t]):D,u)),r.set(t,n)),W(n)===D)?!1:i},set(e,t,n,s){var c=r.get(t),l=t in e;if(i&&t===`length`)for(var d=n;dF(D,u)),r.set(d+``,p)):I(p,D)}if(c===void 0)(!l||a(e,t)?.writable)&&(c=f(()=>F(void 0,u)),I(c,Qt(n)),r.set(t,c));else{l=c.v!==D;var m=f(()=>Qt(n));I(c,m)}var h=Reflect.getOwnPropertyDescriptor(e,t);if(h?.set&&h.set.call(s,n),!l){if(i&&typeof t==`string`){var g=r.get(`length`),_=Number(t);Number.isInteger(_)&&_>=g.v&&I(g,_+1)}Xt(o)}return!0},ownKeys(e){W(o);var t=Reflect.ownKeys(e).filter(e=>{var t=r.get(e);return t===void 0||t.v!==D});for(var[n,i]of r)i.v!==D&&!(n in e)&&t.push(n);return t},setPrototypeOf(){Be()}})}function $t(e){try{if(typeof e==`object`&&e&&ae in e)return e[ae]}catch{}return e}function en(e,t){return Object.is($t(e),$t(t))}var tn,nn,rn,an;function on(){if(tn===void 0){tn=window,nn=/Firefox/.test(navigator.userAgent);var e=Element.prototype,t=Node.prototype,n=Text.prototype;rn=a(t,`firstChild`).get,an=a(t,`nextSibling`).get,u(e)&&(e[ue]=void 0,e[le]=null,e[de]=void 0,e.__e=void 0),u(n)&&(n[fe]=void 0)}}function sn(e=``){return document.createTextNode(e)}function cn(e){return rn.call(e)}function ln(e){return an.call(e)}function L(e,t){if(!O)return cn(e);var n=cn(k);if(n===null)n=k.appendChild(sn());else if(t&&n.nodeType!==3){var r=sn();return n?.before(r),Ce(r),r}return t&&pn(n),Ce(n),n}function R(e,t=!1){if(!O){var n=cn(e);return n instanceof Comment&&n.data===``?ln(n):n}if(t){if(k?.nodeType!==3){var r=sn();return k?.before(r),Ce(r),r}pn(k)}return k}function z(e,t=!1){if(!O)return cn(e);var n=L(e,t);return A(e),n}function B(e,t=1,n=!1){let r=O?k:e;for(var i;t--;)i=r,r=ln(r);if(!O)return r;if(n){if(r?.nodeType!==3){var a=sn();return r===null?i?.after(a):r.before(a),Ce(a),a}pn(r)}return Ce(r),r}function un(e){e.textContent=``}function dn(){return!1}function fn(e,t,n){return t==null||t===`http://www.w3.org/1999/xhtml`?n?document.createElement(e,{is:n}):document.createElement(e):n?document.createElementNS(t,e,{is:n}):document.createElementNS(t,e)}function pn(e){if(e.nodeValue.length<65536)return;let t=e.nextSibling;for(;t!==null&&t.nodeType===3;)t.remove(),e.nodeValue+=t.nodeValue,t=e.nextSibling}function mn(e){var t=U;if(t===null)return H.f|=ie,e;if(!(t.f&32768)&&!(t.f&4))throw e;hn(e,t)}function hn(e,t){if(!(t!==null&&t.f&16384)){for(;t!==null;){if(t.f&128&&!(t.f&33570816)){if(!(t.f&32768))throw e;try{t.b.error(e);return}catch(t){e=t}}t=t.parent}throw e}}function gn(e){U===null&&(H===null&&Ie(e),Fe()),Hn&&Pe(e)}function _n(e,t){var n=t.last;n===null?t.last=t.first=e:(n.next=e,e.prev=n,t.last=e)}function vn(e,t){var n=U;n!==null&&n.f&8192&&(e|=b);var r={ctx:j,deps:null,nodes:null,f:e|v|512,first:null,fn:t,last:null,next:null,parent:n,b:n&&n.b,prev:null,teardown:null,wv:0,ac:null};P?.register_created_effect(r);var i=r;if(e&4)At===null?Pt.ensure().schedule(r):At.push(r);else if(t!==null){try{ur(r)}catch(e){throw Mn(r),e}i.deps===null&&i.teardown===null&&i.nodes===null&&i.first===i.last&&!(i.f&524288)&&(i=i.first,e&16&&e&65536&&i!==null&&(i.f|=w))}if(i!==null&&(i.parent=n,n!==null&&_n(i,n),H!==null&&H.f&2&&!(e&64))){var a=H;(a.effects??=[]).push(i)}return r}function yn(){return H!==null&&!Wn}function bn(e){let t=vn(8,null);return M(t,_),t.teardown=e,t}function xn(e){gn(`$effect`);var t=U.f;if(!H&&t&32&&j!==null&&!j.i){var n=j;(n.e??=[]).push(e)}else return Sn(e)}function Sn(e){return vn(4|ee,e)}function Cn(e){Pt.ensure();let t=vn(64|T,e);return(e={})=>new Promise(n=>{e.outro?Fn(t,()=>{Mn(t),n(void 0)}):(Mn(t),n(void 0))})}function wn(e){return vn(4,e)}function Tn(e){return vn(re|T,e)}function En(e,t=0){return vn(8|t,e)}function V(e,t=[],n=[],r=[]){dt(r,t,n,t=>{vn(8,()=>{e(...t.map(W))})})}function Dn(e,t=0){return vn(16|t,e)}function On(e){return vn(32|T,e)}function kn(e){var t=e.teardown;if(t!==null){let n=Hn,r=H;Un(!0),Gn(null);try{t.call(null)}catch(t){hn(t,e.parent)}finally{Un(n),Gn(r)}}}function An(e,t=!1){var n=e.first;for(e.first=e.last=null;n!==null;){let e=n.ac;e!==null&<(()=>{e.abort(me)});var r=n.next;n.f&64?n.parent=null:Mn(n,t),n=r}}function jn(e){for(var t=e.first;t!==null;){var n=t.next;t.f&32||Mn(t),t=n}}function Mn(e,t=!0){var n=!1;(t||e.f&262144)&&e.nodes!==null&&e.nodes.end!==null&&(Nn(e.nodes.start,e.nodes.end),n=!0),e.f|=C,An(e,t&&!n),lr(e,0);var r=e.nodes&&e.nodes.t;if(r!==null)for(let e of r)e.stop();kn(e),e.f^=C,e.f|=x;var i=e.parent;i!==null&&i.first!==null&&Pn(e),e.next=e.prev=e.teardown=e.ctx=e.deps=e.fn=e.nodes=e.ac=e.b=null}function Nn(e,t){for(;e!==null;){var n=e===t?null:ln(e);e.remove(),e=n}}function Pn(e){var t=e.parent,n=e.prev,r=e.next;n!==null&&(n.next=r),r!==null&&(r.prev=n),t!==null&&(t.first===e&&(t.first=r),t.last===e&&(t.last=n))}function Fn(e,t,n=!0){var r=[];e.f|=256,In(e,r,!0);var i=()=>{n&&Mn(e),t&&t()},a=r.length;if(a>0){var o=()=>--a||i();for(var s of r)s.out(o)}else i()}function In(e,t,n){if(!(e.f&8192)){e.f^=b;var r=e.nodes&&e.nodes.t;if(r!==null)for(let e of r)(e.is_global||n)&&t.push(e);for(var i=e.first;i!==null;){var a=i.next;if(!(i.f&64)){var o=!!(i.f&65536)||!!(i.f&32)&&!!(e.f&16);In(i,t,o?n:!1)}i=a}}}function Ln(e){e.f&=-257,Rn(e,!0)}function Rn(e,t){if(!(e.f&256)&&e.f&8192){e.f^=b,e.f&1024||(M(e,v),Pt.ensure().schedule(e));for(var n=e.first;n!==null;){var r=n.next,i=!!(n.f&65536)||!!(n.f&32);Rn(n,i?t:!1),n=r}var a=e.nodes&&e.nodes.t;if(a!==null)for(let e of a)(e.is_global||t)&&e.in()}}function zn(e,t){if(e.nodes)for(var n=e.nodes.start,r=e.nodes.end;n!==null;){var i=n===r?null:ln(n);t.append(n),n=i}}var Bn=null,Vn=!1,Hn=!1;function Un(e){Hn=e}var H=null,Wn=!1;function Gn(e){H=e}var U=null;function Kn(e){U=e}var qn=null;function Jn(e){H!==null&&(qn??=new Set).add(e)}var Yn=null,Xn=0,Zn=null;function Qn(e){Zn=e}var $n=1,er=0,tr=er;function nr(e){tr=e}function rr(){return++$n}function ir(e){var t=e.f;if(t&2048)return!0;if(t&2&&(e.f&=~te),t&4096){for(var n=e.deps,r=n.length,i=0;ie.wv)return!0}t&512&&Et===null&&M(e,_)}return!1}function ar(e,t,n=!0){var r=e.reactions;if(r!==null&&!(qn!==null&&qn.has(e)))for(var i=0;i{e.ac.abort(me)}),e.ac=null);try{e.f|=ne;var u=e.fn,d=u();e.f|=S;var f=sr(e);if(Ye()&&Zn!==null&&!Wn&&f!==null&&!(e.f&6146))for(var p=0;p0)for(t.length=Xn+Yn.length,r=0;r{s.ac.abort(me),s.ac=null,M(s,v)}),St(s),lr(s,0)}}function lr(e,t){var n=e.deps;if(n!==null)for(var r=t;rn?.call(this,e))}return e.startsWith(`pointer`)||e.startsWith(`touch`)||e===`wheel`?Qe(()=>{t.addEventListener(e,i,r)}):t.addEventListener(e,i,r),i}function xr(e,t,n,r,i){var a={capture:r,passive:i},o=br(e,t,n,a);(t===document.body||t===window||t===document||t instanceof HTMLMediaElement)&&bn(()=>{t.removeEventListener(e,o,a)})}function G(e,t,n){(t[_r]??={})[e]=n}function Sr(e){for(var t=0;t{wr=!1,Cr=null}));var s=0,c=Cr===e&&e[_r];if(c){var l=a.indexOf(c);if(l!==-1&&(t===document||t===window)){e[_r]=t;return}var u=a.indexOf(t);if(u===-1)return;l<=u&&(s=l)}if(o=a[s]||e.target,o!==t){i(e,`currentTarget`,{configurable:!0,get(){return o||n}});var d=H,f=U;Gn(null),Kn(null);try{for(var p,m=[];o!==null&&o!==t;){try{var h=o[_r]?.[r];h!=null&&(!o.disabled||e.target===o)&&h.call(o,e)}catch(e){p?m.push(e):p=e}if(e.cancelBubble)break;s++,o=s{throw e});throw p}}finally{e[_r]=t,delete e.currentTarget,Gn(d),Kn(f)}}}var Er=globalThis?.window?.trustedTypes&&globalThis.window.trustedTypes.createPolicy(`svelte-trusted-html`,{createHTML:e=>e});function Dr(e){return Er?.createHTML(e)??e}function Or(e){var t=fn(`template`);return t.innerHTML=Dr(e.replaceAll(``,``)),t.content}function kr(e,t){var n=U;n.nodes===null&&(n.nodes={start:e,end:t,a:null,t:null})}function K(e,t){var n=!!(t&1),r=!!(t&2),i,a=!e.startsWith(``);return()=>{if(O)return kr(k,null),k;i===void 0&&(i=Or(a?e:``+e),n||(i=cn(i)));var t=r||nn?document.importNode(i,!0):i.cloneNode(!0);if(n){var o=cn(t),s=t.lastChild;kr(o,s)}else kr(t,t);return t}}function Ar(e,t,n=`svg`){var r=!e.startsWith(``),i=!!(t&1),a=`<${n}>${r?e:``+e}`,o;return()=>{if(O)return kr(k,null),k;if(!o){var e=cn(Or(a));if(i)for(o=document.createDocumentFragment();cn(e);)o.appendChild(cn(e));else o=cn(e)}var t=o.cloneNode(!0);if(i){var n=cn(t),r=t.lastChild;kr(n,r)}else kr(t,t);return t}}function jr(e,t){return Ar(e,t,`svg`)}function Mr(){if(O)return kr(k,null),k;var e=document.createDocumentFragment(),t=document.createComment(``),n=sn();return e.append(t,n),kr(t,n),e}function q(e,t){if(O){var n=U;(!(n.f&32768)||n.nodes.end===null)&&(n.nodes.end=k),we();return}e!==null&&e.before(t)}function Nr(){if(O&&k&&k.nodeType===8&&k.textContent?.startsWith(`$`)){let e=k.textContent.substring(1);return we(),e}return(window.__svelte??={}).uid??=1,`c${window.__svelte.uid++}`}function Pr(e){let t=0,n=Gt(0),r;return()=>{yn()&&(W(n),En(()=>(t===0&&(r=mr(()=>e(()=>Xt(n)))),t+=1,()=>{Qe(()=>{--t,t===0&&(r?.(),r=void 0,Xt(n))})})))}}var Fr=w|T;function Ir(e,t,n,r){new Lr(e,t,n,r)}var Lr=class{parent;is_pending=!1;transform_error;#e;#t=O?k:null;#n;#r;#i;#a=null;#o=null;#s=null;#c=null;#l=0;#u=0;#d=!1;#f=new Set;#p=new Set;#m=null;#h=Pr(()=>(this.#m=Gt(this.#l),()=>{this.#m=null}));constructor(e,t,n,r){this.#e=e,this.#n=t,this.#r=e=>{var t=U;t.b=this,t.f|=128,n(e)},this.parent=U.b,this.transform_error=r??this.parent?.transform_error??(e=>e),this.#i=Dn(()=>{if(O){let e=this.#t;we();let t=e.data===`[!`;if(e.data.startsWith(`[?`)){let t=JSON.parse(e.data.slice(2));this.#_(t)}else t?this.#y():this.#g()}else this.#b()},Fr),O&&(this.#e=k)}#g(){try{this.#a=On(()=>this.#r(this.#e))}catch(e){this.error(e)}}#_(e){let t=this.#n.failed,{reset:n,invoke_onerror:r}=this.#v(e);Qe(r),t&&(this.#s=On(()=>{t(this.#e,()=>e,()=>n)}))}#v(e){var t=!1,n=!1;let r=()=>{if(t){xe();return}t=!0,n&&He(),this.#s!==null&&Fn(this.#s,()=>{this.#s=null}),this.#S(()=>{this.#b()})};return{reset:r,invoke_onerror:()=>{try{n=!0,this.#n.onerror?.(e,r),n=!1}catch(e){hn(e,this.#i&&this.#i.parent)}}}}#y(){let e=this.#n.pending;e&&(this.is_pending=!0,this.#o=On(()=>e(this.#e)),Qe(()=>{var e=this.#c=document.createDocumentFragment(),t=sn(),n=!1;if(e.append(t),this.#a=this.#S(()=>{try{return On(()=>this.#r(t))}catch(e){try{this.error(e),n=!0}catch(e){hn(e,this.#i.parent)}return null}}),this.#a===null){this.#c=null,n&&this.#x(P);return}this.#u===0&&(this.#e.before(e),this.#c=null,Fn(this.#o,()=>{this.#o=null}),this.#x(P))}))}#b(){try{if(this.is_pending=this.has_pending_snippet(),this.#u=0,this.#l=0,this.#a=On(()=>{this.#r(this.#e)}),this.#u>0){var e=this.#c=document.createDocumentFragment();zn(this.#a,e);let t=this.#n.pending;this.#o=On(()=>t(this.#e))}else this.#x(P)}catch(e){this.error(e)}}#x(e){this.is_pending=!1,e.transfer_effects(this.#f,this.#p)}defer_effect(e){rt(e,this.#f,this.#p)}is_rendered(){return!this.is_pending&&(!this.parent||this.parent.is_rendered())}has_pending_snippet(){return!!this.#n.pending}#S(e){var t=U,n=H,r=j;Kn(this.#i),Gn(this.#i),Ge(this.#i.ctx);try{return Pt.ensure(),e()}finally{Kn(t),Gn(n),Ge(r)}}#C(e,t){if(!this.has_pending_snippet()){this.parent&&this.parent.#C(e,t);return}this.#u+=e,this.#u===0&&(this.#x(t),this.#o&&Fn(this.#o,()=>{this.#o=null}),this.#c&&=(this.#e.before(this.#c),null))}update_pending_count(e,t){this.#C(e,t),this.#l+=e,!(!this.#m||this.#d)&&(this.#d=!0,Qe(()=>{this.#d=!1,this.#m&&qt(this.#m,this.#l)}))}get_effect_pending(){return this.#h(),W(this.#m)}error(e){if(!this.#n.onerror&&!this.#n.failed)throw e;P?.is_fork?(this.#a&&P.skip_effect(this.#a),this.#o&&P.skip_effect(this.#o),this.#s&&P.skip_effect(this.#s),P.oncommit(()=>{this.#w(e)})):this.#w(e)}#w(e){this.#a&&=(Mn(this.#a),null),this.#o&&=(Mn(this.#o),null),this.#s&&=(Mn(this.#s),null),O&&(Ce(this.#t),Te(),Ce(Ee()));let t=this.#n.failed,n=e=>{let{reset:n,invoke_onerror:r}=this.#v(e);r(),t&&(this.#s=this.#S(()=>{try{return On(()=>{var r=U;r.b=this,r.f|=128,t(this.#e,()=>e,()=>n)})}catch(e){return hn(e,this.#i.parent),null}}))};Qe(()=>{var t;try{t=this.transform_error(e)}catch(e){hn(e,this.#i&&this.#i.parent);return}typeof t==`object`&&t&&typeof t.then==`function`?t.then(n,e=>hn(e,this.#i&&this.#i.parent)):n(t)})}};function J(e,t){var n=t==null?``:typeof t==`object`?`${t}`:t;n!==(e[fe]??=e.nodeValue)&&(e[fe]=n,e.nodeValue=`${n}`)}function Rr(e,t){return Br(e,t)}var zr=new Map;function Br(e,{target:t,anchor:n,props:i={},events:a,context:o,intro:s=!0,transformError:c}){on();var l=void 0,u=Cn(()=>{var s=n??t.appendChild(sn());Ir(s,{pending:()=>{}},t=>{Ke({});var n=j;if(o&&(n.c=o),a&&(i.$$events=a),O&&kr(t,null),l=e(t,i)||Je(),O&&(U.nodes.end=k,k===null||k.nodeType!==8||k.data!==`]`))throw ye(),ge;qe()},c);var u=new Set,d=e=>{for(var n=0;n{for(var e of u)for(let n of[t,document]){var r=zr.get(n),i=r.get(e);--i==0?(n.removeEventListener(e,Tr),r.delete(e),r.size===0&&zr.delete(n)):r.set(e,i)}yr.delete(d),s!==n&&s.parentNode?.removeChild(s)}});return Vr.set(l,u),l}var Vr=new WeakMap,Hr=class{anchor;#e=new Map;#t=new Map;#n=new Map;#r=new Set;#i=!0;constructor(e,t=!0){this.anchor=e,this.#i=t}#a=e=>{if(this.#e.has(e)){var t=this.#e.get(e),n=this.#t.get(t);if(n)Ln(n),this.#r.delete(t);else{var r=this.#n.get(t);r&&(Ln(r.effect),this.#t.set(t,r.effect),this.#n.delete(t),r.fragment.lastChild.remove(),this.anchor.before(r.fragment),n=r.effect)}for(let[t,n]of this.#e){if(this.#e.delete(t),t===e)break;let r=this.#n.get(n);r&&(Mn(r.effect),this.#n.delete(n))}for(let[e,r]of this.#t){if(e===t||this.#r.has(e))continue;let i=()=>{if(Array.from(this.#e.values()).includes(e)){var t=document.createDocumentFragment();zn(r,t),t.append(sn()),this.#n.set(e,{effect:r,fragment:t})}else Mn(r);this.#r.delete(e),this.#t.delete(e)};this.#i||!n?(this.#r.add(e),Fn(r,i,!1)):i()}}};#o=e=>{this.#e.delete(e);let t=Array.from(this.#e.values());for(let[e,n]of this.#n)t.includes(e)||(Mn(n.effect),this.#n.delete(e))};ensure(e,t){var n=P,r=dn();if(t&&!this.#t.has(e)&&!this.#n.has(e)){if(r){var i=document.createDocumentFragment(),a=sn();i.append(a),this.#n.set(e,{effect:On(()=>t(a)),fragment:i})}else this.#t.set(e,On(()=>t(this.anchor)))}if(this.#e.set(n,e),r){for(let[t,r]of this.#t)t===e?n.unskip_effect(r):n.skip_effect(r);for(let[t,r]of this.#n)t===e?n.unskip_effect(r.effect):n.skip_effect(r.effect);n.oncommit(this.#a),n.ondiscard(this.#o)}else O&&(this.anchor=k),this.#a(n)}};function Y(e,t,n=!1){var r;O&&(r=k,we());var i=new Hr(e),a=n?w:0;function o(e,t){if(O){var n=De(r);if(e!==parseInt(n.substring(1))){var a=Ee();Ce(a),i.anchor=a,Se(!1),i.ensure(e,t),Se(!0);return}}i.ensure(e,t)}Dn(()=>{var e=!1;t((t,n=0)=>{e=!0,o(n,t)}),e||o(-1,null)},a)}var Ur=Symbol(`NaN`);function Wr(e,t,n){O&&we();var r=new Hr(e),i=!Ye();Dn(()=>{var e=t();e!==e&&(e=Ur),i&&typeof e==`object`&&e&&(e={}),r.ensure(e,n)})}function X(e,t){return t}function Gr(e,t,n){for(var i=[],a=t.length,o,s=t.length,c=0;c{if(o){if(o.pending.delete(n),o.done.add(n),o.pending.size===0){var t=e.outrogroups;Kr(e,r(o.done)),t.delete(o),t.size===0&&(e.outrogroups=null)}}else--s},!1)}if(s===0){var l=i.length===0&&n!==null&&e.pending.size===0;if(l){var u=n,d=u.parentNode;un(d),d.append(u),e.items.clear()}Kr(e,t,!l)}else o={pending:new Set(t),done:new Set},(e.outrogroups??=new Set).add(o)}function Kr(e,t,n=!0){var r;if(e.pending.size>0){r=new Set;for(let t of e.pending.values())for(let n of t)r.add(e.items.get(n).e)}for(var i=0;i{var t=i();return e(t)?t:t==null?[]:r(t)}),p,m=new Map,h=!0;function g(e){v.effect.f&16384||(v.pending.delete(e),v.fallback=d,Yr(v,p,c,n,a),d!==null&&(p.length===0?d.f&33554432?(d.f^=E,Zr(d,null,c)):Ln(d):Fn(d,()=>{d=null})))}function _(e){v.pending.delete(e)}var v={effect:Dn(()=>{p=W(f);var e=p.length;let t=!1;O&&De(c)===`[!`!=(e===0)&&(c=Ee(),Ce(c),Se(!1),t=!0);for(var r=new Set,u=P,v=dn(),y=0;ys(c)):(d=On(()=>s(qr??=sn())),d.f|=E)),e>r.size&&Ne(``,``,``),O&&e>0&&Ce(Ee()),!h){if(m.set(u,r),v){for(let[e,t]of l)r.has(e)||u.skip_effect(t.e);u.oncommit(g),u.ondiscard(_)}else g(u)}t&&Se(!0),W(f)}),flags:n,items:l,pending:m,outrogroups:null,fallback:d};h=!1,O&&(c=k)}function Jr(e){for(;e!==null&&!(e.f&32);)e=e.next;return e}function Yr(e,t,n,i,a){var o=!!(i&8),s=t.length,c=e.items,l=Jr(e.effect.first),u,d=null,f,p=[],m=[],h,g,_,v;if(o)for(v=0;v0){var ee=i&4&&s===0?n:null;if(o){for(v=0;v{if(f!==void 0)for(_ of f)_.nodes?.a?.apply()})}function Xr(e,t,n,r,i,a,o,s){var c=o&1?o&16?Gt(n):Kt(n,!1,!1):null,l=o&2?Gt(i):null;return{v:c,i:l,e:On(()=>(a(t,c??n,l??i,s),()=>{e.delete(r)}))}}function Zr(e,t,n){if(e.nodes)for(var r=e.nodes.start,i=e.nodes.end,a=t&&!(t.f&33554432)?t.nodes.start:n;r!==null;){var o=ln(r);if(a.before(r),r===i)return;r=o}}function Qr(e,t,n){t===null?e.effect.first=n:t.next=n,n===null?e.effect.last=t:n.prev=t}function $r(e,t,n){var r;O&&(r=k,we());var i=new Hr(e);Dn(()=>{var e=t()??null;if(O&&De(r)===`[`!=(e!==null)){var a=Ee();Ce(a),i.anchor=a,Se(!1),i.ensure(e,e&&(t=>n(t,e))),Se(!0);return}i.ensure(e,e&&(t=>n(t,e)))},w)}var ei=[...` +\r\f\xA0\v`];function ti(e,t,n){var r=e==null?``:``+e;if(t&&(r=r?r+` `+t:t),n){for(var i of Object.keys(n))if(n[i])r=r?r+` `+i:i;else if(r.length)for(var a=i.length,o=0;(o=r.indexOf(i,o))>=0;){var s=o+a;(o===0||ei.includes(r[o-1]))&&(s===r.length||ei.includes(r[s]))?r=(o===0?``:r.substring(0,o))+r.substring(s+1):o=s}}return r===``?null:r}function ni(e,t=!1){var n=t?` !important;`:`;`,r=``;for(var i of Object.keys(e)){var a=e[i];a!=null&&a!==``&&(r+=` `+i+`: `+a+n)}return r}function ri(e){return e[0]!==`-`||e[1]!==`-`?e.toLowerCase():e}function ii(e,t){if(t){var n=``,r,i;if(Array.isArray(t)?(r=t[0],i=t[1]):r=t,e){e=String(e).replaceAll(/\/\*.*?\*\//g,``).trim();var a=!1,o=0,s=!1,c=[];r&&c.push(...Object.keys(r).map(ri)),i&&c.push(...Object.keys(i).map(ri));var l=0,u=-1;let t=e.length;for(var d=0;d{t.every(mi)||(`__defaultValue`in e&&li(e,!1),`__value`in e&&ui(e,e.__value))});t.observe(e,{childList:!0,subtree:!0,attributes:!0,attributeFilter:[`value`]}),bn(()=>{t.disconnect()})}function fi(e,t,n=t){var r=new WeakSet,i=!0;ut(e,`change`,t=>{var i=t?`[selected]`:`:checked`,a;if(e.multiple)a=[].map.call(e.querySelectorAll(i),pi);else{var o=e.querySelector(i)??e.querySelector(`option:not([disabled])`);a=o&&pi(o)}n(a),e.__value=a,P!==null&&r.add(P)}),wn(()=>{var a=t();if(e===document.activeElement){var o=P;if(r.has(o))return}if(ui(e,a,i),i&&a===void 0){var s=e.querySelector(`:checked`);s!==null&&(a=pi(s),n(a))}e.__value=a,i=!1})}function pi(e){return`__value`in e?e.__value:e.value}function mi(e){if(e.target.closest(`selectedcontent`)!==null)return!0;if(e.type===`childList`){var t=[...e.addedNodes,...e.removedNodes];return t.length>0&&t.every(e=>e.nodeName===`SELECTEDCONTENT`)}return!1}var hi=Symbol(`is custom element`),gi=Symbol(`is html`),_i=he?`link`:`LINK`;function vi(e){if(O){var t=!1,n=()=>{if(!t){if(t=!0,e.hasAttribute(`value`)){var n=e.value;Q(e,`value`,null),e.value=n}if(e.hasAttribute(`checked`)){var r=e.checked;Q(e,`checked`,null),e.checked=r}}};e[pe]=n,Qe(n),ct()}}function Q(e,t,n,r){var i=yi(e);O&&(i[t]=e.getAttribute(t),t===`src`||t===`srcset`||t===`href`&&e.nodeName===_i)||i[t]!==(i[t]=n)&&(t===`loading`&&(e[ce]=n),n==null?e.removeAttribute(t):typeof n!=`string`&&xi(e).has(t)?e[t]=n:e.setAttribute(t,n))}function yi(e){return e[le]??={[hi]:e.nodeName.includes(`-`),[gi]:e.namespaceURI===_e}}var bi=new Map;function xi(e){var t=e.getAttribute(`is`)||e.nodeName,n=bi.get(t);if(n)return n;bi.set(t,n=new Set);for(var r,i=e,a=Element.prototype;a!==i;){for(var s in r=o(i),r)r[s].set&&s!==`innerHTML`&&s!==`textContent`&&s!==`innerText`&&n.add(s);i=l(i)}return n}function Si(e,t,n=t){var r=new WeakSet;ut(e,`input`,async i=>{var a=i?e.defaultValue:e.value;if(a=Ei(e)?Di(a):a,n(a),P!==null&&r.add(P),await dr(),a!==(a=t())){var o=e.selectionStart,s=e.selectionEnd,c=e.value.length;if(e.value=a??``,s!==null){var l=e.value.length;o===s&&s===c&&l>c?(e.selectionStart=l,e.selectionEnd=l):(e.selectionStart=o,e.selectionEnd=Math.min(s,l))}}}),(O&&e.defaultValue!==e.value||mr(t)==null&&e.value)&&(n(Ei(e)?Di(e.value):e.value),P!==null&&r.add(P)),En(()=>{var n=t();if(e===document.activeElement){var i=P;if(r.has(i))return}Ei(e)&&n===Di(e.value)||(e.type!==`date`||n||e.value)&&n!==e.value&&(e.value=n??``)})}var Ci=new Set;function wi(e,t,n,r,i=r){var a=n.getAttribute(`type`)===`checkbox`,o=e;let s=!1;if(t!==null)for(var c of t)o=o[c]??=[];o.push(n),ut(n,`change`,()=>{var e=n.__value;a&&(e=Ti(o,e,n.checked)),i(e)},()=>i(a?[]:null)),En(()=>{var e=r();if(O&&n.defaultChecked!==n.checked){s=!0;return}a?(e||=[],n.checked=e.includes(n.__value)):n.checked=en(n.__value,e)}),bn(()=>{var e=o.indexOf(n);e!==-1&&o.splice(e,1)}),Ci.has(o)||(Ci.add(o),Qe(()=>{o.sort((e,t)=>e.compareDocumentPosition(t)===4?-1:1),Ci.delete(o)})),Qe(()=>{if(s){var e=a?Ti(o,e,n.checked):o.find(e=>e.checked)?.__value;i(e)}})}function Ti(e,t,n){for(var r=new Set,i=0;i{var n=this.#e.get(e);n.delete(t),n.size===0&&(this.#e.delete(e),this.#t.unobserve(e))}}#r(){return this.#t??=new ResizeObserver(t=>{for(var n of t){e.entries.set(n.target,n);for(var r of this.#e.get(n.target)||[])r(n)}})}}({box:`border-box`});function ki(e,t,n){var r=Oi.observe(e,()=>n(e[t]));wn(()=>(mr(()=>n(e[t])),r))}function Ai(e,t,n,r,i){var a=()=>{r(n[e])};n.addEventListener(t,a),i?En(()=>{n[e]=i()}):a(),(n===document.body||n===window||n===document)&&bn(()=>{n.removeEventListener(t,a)})}var ji={get(e,t){let n=e.props.length;for(;n--;){let r=e.props[n];if(d(r)&&(r=r()),typeof r==`object`&&r&&t in r)return r[t]}},set(e,t,n){let r=e.props.length;for(;r--;){let i=e.props[r];d(i)&&(i=i());let o=a(i,t);if(o&&o.set)return o.set(n),!0}return!1},getOwnPropertyDescriptor(e,t){let n=e.props.length;for(;n--;){let r=e.props[n];if(d(r)&&(r=r()),typeof r==`object`&&r&&t in r){let e=a(r,t);return e&&!e.configurable&&(e.configurable=!0),e}}},has(e,t){if(t===ae||t===se)return!1;for(let n of e.props)if(d(n)&&(n=n()),n!=null&&t in n)return!0;return!1},ownKeys(e){let t=[];for(let n of e.props)if(d(n)&&(n=n()),n){for(let e in n)t.includes(e)||t.push(e);for(let e of Object.getOwnPropertySymbols(n))t.includes(e)||t.push(e)}return t}};function Mi(...e){return new Proxy({props:e},ji)}function Ni(e,t,n,r){var i=!Ue||!!(n&2),o=!!(n&8),s=!!(n&16),c=r,l=!0,u=void 0,d=()=>s&&i?(u??=ht(r),W(u)):(l&&(l=!1,c=s?mr(r):r),c);let f;if(o){var p=ae in e||se in e;f=a(e,t)?.set??(p&&t in e?n=>e[t]=n:void 0)}var m,h=!1;o?[m,h]=at(()=>e[t]):m=e[t],m===void 0&&r!==void 0&&(m=d(),f&&(i&&Re(t),f(m)));var g=i?()=>{var n=e[t];return n===void 0?d():(l=!0,n)}:()=>{var n=e[t];return n!==void 0&&(c=void 0),n===void 0?c:n};if(i&&!(n&4))return g;if(f){var _=e.$$legacy;return(function(e,t){return arguments.length>0?((!i||!t||_||h)&&f(t?g():e),e):g()})}var v=!1,y=(n&1?ht:vt)(()=>(v=!1,g()));o&&W(y);var b=U;return(function(e,t){if(arguments.length>0){let n=t?W(y):i&&o?Qt(e):e;return I(y,n),v=!0,c!==void 0&&(c=n),e}return Hn&&v||b.f&16384?y.v:W(y)})}function Pi(e){j===null&&je(`onMount`),Ue&&j.l!==null?Fi(j).m.push(e):xn(()=>{let t=mr(e);if(typeof t==`function`)return t})}function Fi(e){var t=e.l;return t.u??={a:[],b:[],m:[]}}typeof window<`u`&&((window.__svelte??={}).v??=new Set).add(`5`);function Ii(e,t){if(e instanceof RegExp)return{keys:!1,pattern:e};var n,r,i,a,o=[],s=``,c=e.split(`/`);for(c[0]||c.shift();i=c.shift();)n=i[0],n===`*`?(o.push(`wild`),s+=`/(.*)`):n===`:`?(r=i.indexOf(`?`,1),a=i.indexOf(`.`,1),o.push(i.substring(1,~r?r:~a?a:i.length)),s+=~r&&!~a?`(?:/([^/]+?))?`:`/([^/]+?)`,~a&&(s+=(~r?`?`:``)+`\\`+i.substring(a))):s+=`/`+i;return{keys:o,pattern:RegExp(`^`+s+(t?`(?=$|/)`:`/?$`),`i`)}}var Li=new class{#e=F(Ri());get _loc(){return W(this.#e)}set _loc(e){I(this.#e,e)}#t=N(()=>this._loc.location);get _location(){return W(this.#t)}set _location(e){I(this.#t,e)}#n=N(()=>this._loc.querystring);get _querystring(){return W(this.#n)}set _querystring(e){I(this.#n,e)}#r=F(void 0);get _params(){return W(this.#r)}set _params(e){I(this.#r,e)}get loc(){return this._loc}get location(){return this._location}get querystring(){return this._querystring}get params(){return this._params}constructor(){typeof window<`u`?window.addEventListener(`hashchange`,()=>{this._loc=Ri()}):console.warn(`[svelte-spa-router] window 'window' is not defined, skipping initiation`)}};function Ri(){let e=typeof window<`u`?window.location.href:``,t=e.indexOf(`#/`),n=t>-1?e.substr(t+1):`/`,r=n.indexOf(`?`),i=``;return r>-1&&(i=n.substr(r+1),n=n.substr(0,r)),{location:n,querystring:i}}function zi(e){e?window.scrollTo(e.__svelte_spa_router_scrollX||0,e.__svelte_spa_router_scrollY||0):window.scrollTo(0,0)}function Bi(e,t){Ke(t,!0);let n=Ni(t,`routes`,19,()=>({})),r=Ni(t,`prefix`,3,``),i=Ni(t,`restoreScrollState`,3,!1),a=Ni(t,`onConditionsFailed`,3,()=>{}),o=Ni(t,`onRouteLoaded`,3,()=>{}),s=Ni(t,`onRouteLoading`,3,()=>{}),c=Ni(t,`onRouteEvent`,3,()=>{});class l{path;component;conditions;userData;props;_pattern;_keys;constructor(e,t){let n=e=>typeof e==`object`&&!!e&&e._sveltesparouter===!0;if(!t||typeof t!=`function`&&!n(t))throw Error(`Invalid component object`);if(!e||typeof e==`string`&&(e.length<1||e.charAt(0)!=`/`&&e.charAt(0)!=`*`)||typeof e==`object`&&!(e instanceof RegExp))throw Error(`Invalid value for "path" argument - strings must start with / or *`);let r=Ii(e);if(this.path=e,n(t)){let e=t;this.component=e.component,this.conditions=e.conditions||[],this.userData=e.userData,this.props=e.props||{}}else{let e=t;this.component=()=>Promise.resolve(e),this.conditions=[],this.props={}}this._pattern=r.pattern,this._keys=r.keys}match(e){if(r()){if(typeof r()==`string`){if(e.startsWith(r()))e=e.substr(r().length)||`/`;else return null}else if(r()instanceof RegExp){let t=e.match(r());if(t&&t[0])e=e.substr(t[0].length)||`/`;else return null}}let t=this._pattern.exec(e);if(t===null)return null;if(this._keys===!1)return t;let n={},i=0;for(;i{u.push(new l(t,e))}):Object.keys(n()).forEach(e=>{let t=n();u.push(new l(e,t[e]))});let d=F(null),f=F(null),p=F({}),m=null,h=null;xn(()=>{history.scrollRestoration=i()?`manual`:`auto`}),xn(()=>{if(!i())return;let e=e=>{m=e.state&&(e.state.__svelte_spa_router_scrollY||e.state.__svelte_spa_router_scrollX)?e.state:null};return window.addEventListener(`popstate`,e),()=>window.removeEventListener(`popstate`,e)});async function g(e,t){await dr(),e(t)}xn(()=>{let e=Li.loc,t=!1;return mr(async()=>{let n=0;for(;n{t=!0}});function _(e){return e&&typeof e==`object`&&Object.keys(e).length?e:null}var v=Mr(),y=R(v),b=e=>{let t=N(()=>W(d));var n=Mr(),r=R(n),i=e=>{var n=Mr();$r(R(n),()=>W(t),(e,t)=>{t(e,Mi({get params(){return W(f)},get onRouteEvent(){return c()}},()=>W(p)))}),q(e,n)},a=e=>{var n=Mr();$r(R(n),()=>W(t),(e,t)=>{t(e,Mi({get onRouteEvent(){return c()}},()=>W(p)))}),q(e,n)};Y(r,e=>{W(f)?e(i):e(a,-1)}),q(e,n)};Y(y,e=>{W(d)&&e(b)}),q(e,v),qe()}var Vi=[`bars`,`pace`,`zone`,`pie`,`fuel`,`resets`],Hi=`codexometer.web.preferences.v1`,Ui={tab:`quota`,view:`bars`,selected:``,defaultDetail:0,layouts:[]};function Wi(){try{let e=JSON.parse(localStorage.getItem(Hi)||`null`);return!e||typeof e!=`object`?Ui:{tab:[`quota`,`sessions`,`usage`].includes(e.tab)?e.tab:`quota`,view:Vi.includes(e.view)?e.view:`bars`,selected:typeof e.selected==`string`?e.selected.slice(0,256):``,defaultDetail:+(e.defaultDetail===1),layouts:Array.isArray(e.layouts)?e.layouts.filter(e=>{if(!e||typeof e!=`object`)return!1;let t=e;return typeof t.id==`string`&&t.id.length<=256&&[0,1,2].includes(t.level)}).slice(-100):[]}}catch{return Ui}}var Gi=Qt(Wi());function Ki(){let e=JSON.stringify(Gi);try{localStorage.setItem(Hi,e)}catch{}}function qi(){return Gi.tab===`quota`?`/quota/`+Gi.view:`/`+Gi.tab}function Ji(e){return Gi.layouts.find(t=>t.id===e)?.level??Gi.defaultDetail}function Yi(e){Gi.defaultDetail=e,Gi.layouts=[]}function Xi(e,t){Gi.layouts=[...Gi.layouts.filter(t=>t.id!==e),{id:e,level:Math.max(0,Math.min(2,t))}].slice(-100)}var $=Qt({data:null,connected:!1,error:``}),Zi=e=>e==null?`Unavailable`:e.toLocaleString(`en-GB`),Qi=e=>!e||String(e).startsWith(`0001-`)?`Not yet available`:new Date(typeof e==`number`?e*1e3:e).toLocaleString(`en-GB`),$i=`codexometer.web.session`,ea;async function ta(e,t,n){if(!ea||!$.connected||!$.data?.control||$.data.sessionsError)throw Error(`Session controls unavailable. Check the live connection.`);return await ea(e,t,n)}function na(){let e=new AbortController,t,n=``;ea=async(t,r,i)=>{let a=await fetch(`/api/control/`+t,{method:`POST`,headers:{Authorization:`Bearer ${n}`,"Content-Type":`application/json`},body:JSON.stringify(r),signal:AbortSignal.any([e.signal,AbortSignal.timeout(8e3),...i?[i]:[]]),cache:`no-store`});if(!a.ok)throw Error(a.status===502?`Outcome uncertain. Check Codex before taking another action; nothing was retried.`:`Action unavailable, expired or changed. Refresh and check Codex.`);return a.json()};try{n=sessionStorage.getItem($i)||``}catch{}let r=location.hash,i=r.startsWith(`#pair=`)?r.slice(6):``;(i||!r||r===`#`)&&(history.replaceState(null,``,`/#`+qi()),window.dispatchEvent(new HashChangeEvent(`hashchange`)));async function a(){if(i)try{let t=await fetch(`/api/pair`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({secret:i}),signal:e.signal});if(!t.ok)throw Error(`Pairing link expired or already used. Restart --web for a fresh link.`);n=(await t.json()).token;try{sessionStorage.setItem($i,n)}catch{}}catch(t){e.signal.aborted||($.error=t instanceof Error?t.message:`Pairing failed`);return}if(!n){$.error=`Open the private pairing link printed by codexometer --web in your terminal.`;return}await o()}async function o(){try{let t=await fetch(`/api/events`,{headers:{Authorization:`Bearer ${n}`},signal:e.signal,cache:`no-store`});if(t.status===401){try{sessionStorage.removeItem($i)}catch{}$.error=`Browser session expired. Restart --web and open its new pairing link.`;return}if(!t.ok||!t.body)throw Error(`Live connection unavailable`);let r=t.body.getReader(),i=new TextDecoder,a=``;for(;!e.signal.aborted;){let{done:e,value:t}=await r.read();if(e)break;a+=i.decode(t,{stream:!0});let n;for(;(n=a.indexOf(` + +`))>=0;){let e=a.slice(0,n);a=a.slice(n+2),e.startsWith(`data: `)&&($.data=JSON.parse(e.slice(6)),$.connected=!0,$.error=``)}}}catch{}finally{$.connected=!1}e.signal.aborted||($.error=`Connection lost — showing last observation. Reconnecting…`,t=setTimeout(o,2500))}return a(),()=>{ea=void 0,e.abort(),clearTimeout(t),$.connected=!1}}var ra=jr(` `,1),ia=jr(` `,1),aa=K(` `),oa=K(`
Quota observations
Observed atPeriod elapsedConsumedTrail segment
`),sa=K(`


Observed quota + path, not individual session usage. Gaps are not interpolated. Expand the + observation table for times, positions and gaps.

OBSERVATION TABLE
`,1),ca=K(`
CONSUMPTIONQUOTA PERIOD ELAPSED


`,1);function la(e,t){let n=Nr();Ke(t,!0);let r=Ni(t,`trail`,19,()=>[]),i=F(!1),a=[0,25,50,75,100],o=F(400),s=F(240),c=N(()=>W(o)-24),l=N(()=>W(s)-48),u=N(()=>Math.max(1,W(c)-48)),d=N(()=>Math.max(1,W(l)-20)),f=N(()=>48+Math.max(0,Math.min(100,t.elapsed))/100*W(u)),p=N(()=>W(l)-Math.max(0,Math.min(100,t.used))/100*W(d)),m=N(()=>t.used-t.elapsed),h=N(()=>r().map((e,t)=>`${t===0||e.break?`M`:`L`}${48+e.elapsed/100*W(u)} ${W(l)-e.used/100*W(d)}`).join(` `));var g=ca(),_=R(g),v=L(_),y=L(v),b=z(y),x=B(y),S=B(x);Z(S,17,()=>a,X,(e,t)=>{var n=ra(),r=R(n),i=B(r),a=B(i),o=z(a),s=B(a),f=z(s);V(()=>{Q(r,`x1`,48+W(t)/100*W(u)),Q(r,`x2`,48+W(t)/100*W(u)),Q(r,`y2`,W(l)),Q(i,`y1`,W(l)-W(t)/100*W(d)),Q(i,`x2`,W(c)),Q(i,`y2`,W(l)-W(t)/100*W(d)),Q(a,`x`,48+W(t)/100*W(u)),Q(a,`y`,W(l)+20),J(o,`${W(t)??``}%`),Q(s,`y`,W(l)+4-W(t)/100*W(d)),J(f,`${W(t)??``}%`)}),q(e,n)});var C=B(S),w=B(C),T=B(w),ee=e=>{var t=ia(),n=R(t),i=B(n),a=z(L(i));A(i),V(e=>{Q(n,`d`,W(h)),Q(i,`cx`,48+r()[0].elapsed/100*W(u)),Q(i,`cy`,W(l)-r()[0].used/100*W(d)),J(a,`First observation: ${e??``}`)},[()=>Qi(r()[0].at)]),q(e,t)};Y(T,e=>{r().length&&e(ee)});var E=B(T,2),te=B(E),ne=B(te),re=z(L(ne));A(ne),A(v),A(_);var ie=B(_,2),ae=L(ie),oe=z(B(ae,3),!0);A(ie);var se=B(ie,2),ce=e=>{var t=sa(),a=R(t),o=L(a);Te(2),A(a);var s=B(a,2),c=B(L(s),2),l=e=>{var t=oa(),n=L(t),i=B(L(n),2);Z(i,21,r,X,(e,t,n)=>{var r=aa(),i=L(r),a=L(i),o=z(a,!0);A(i);var s=B(i),c=z(s),l=B(s),u=z(l),d=z(B(l),!0);A(r),V((e,r)=>{Q(a,`datetime`,W(t).at),J(o,e),J(c,`${r??``}%`),J(u,`${W(t).used??``}%`),J(d,n===0?`First observation`:W(t).break?`Gap before this observation`:`Connected to previous observation`)},[()=>Qi(W(t).at),()=>W(t).elapsed.toFixed(1)]),q(e,r)}),A(i),A(n),A(t),q(e,t)};Y(c,e=>{W(i)&&e(l)}),A(s),V(e=>{Q(a,`id`,n+`-trail-summary`),J(o,`○ START ${e??``} // ${r().length??``} + ${r().length===1?`OBSERVATION`:`OBSERVATIONS`}`)},[()=>Qi(r()[0].at)]),Ai(`open`,`toggle`,s,e=>I(i,e),()=>W(i)),q(e,t)};Y(se,e=>{r().length&&e(ce)}),V((e,i,a,m)=>{Q(v,`viewBox`,`0 0 ${W(o)} ${W(s)}`),Q(v,`aria-describedby`,r().length?n+`-trail-summary`:void 0),Q(v,`aria-label`,e),Q(b,`id`,n),Q(x,`width`,W(u)),Q(x,`height`,W(d)),Q(x,`fill`,`url(#${n})`),Q(C,`d`,`M48 20 V${W(l)} H${W(c)}`),Q(w,`y1`,W(l)),Q(w,`x2`,W(c)),Q(E,`x`,48+W(u)/2),Q(E,`y`,W(s)-5),Q(te,`cx`,W(f)),Q(te,`cy`,W(p)),Q(ne,`cx`,W(f)),Q(ne,`cy`,W(p)),J(re,`${t.used??``}% consumed // ${i??``}% of period elapsed`),J(ae,`${t.used??``}% USED // ${a??``}% TIME ELAPSED`),J(oe,m)},[()=>`Consumption zone: ${t.used}% consumed, ${t.elapsed.toFixed(1)}% of quota period elapsed. ${W(m)>0?`Above`:W(m)<0?`Below`:`On`} the steady-consumption line.`,()=>t.elapsed.toFixed(1),()=>t.elapsed.toFixed(1),()=>Math.abs(W(m))<.05?`ON PACE`:W(m)>0?`ABOVE THE LINE — CONSUMING FASTER THAN TIME`:`BELOW THE LINE — WITHIN PACE`]),ki(_,`clientWidth`,e=>I(o,e)),ki(_,`clientHeight`,e=>I(s,e)),q(e,g),qe()}var ua=K(` `),da=K(`

Quota refresh failed. Values below are the last successful observation.

`),fa=K(`

Expiry details unavailable. No listed expiry does not mean no expiry.

`),pa=K(`

`),ma=K(`

Read-only preview. Use the terminal to redeem a reset.

The backend may return only some credits. This list does not establish + redemption order.

`),ha=K(` `,1),ga=jr(``),_a=jr(``),va=K(`
`),ya=K(`

Cycle duration or reset date unavailable — position cannot be + plotted.

`),ba=K(`
−100 // OVER BUDGET+100 // HEADROOM

`,1),xa=K(`

Cycle duration unavailable — pace cannot be calculated.

`),Sa=K(`
EMPTYFULL
`),Ca=K(`

`,1),wa=K(`
`,1),Ta=K(`

`),Ea=K(`

`),Da=K(`

No quota windows reported yet.

`),Oa=K(`
`,1),ka=K(`

All reported windows are shown. API-equivalent learning and quota status + scoring remain in the terminal for this first preview.

`,1),Aa=K(` `,1);function ja(e,t){Ke(t,!0);let n=Ni(t,`params`,19,()=>({})),r=Vi,i=F(Qt(Date.now())),a=N(()=>r.includes(n().view||``)?n().view:`bars`);Pi(()=>{let e=setInterval(()=>I(i,Date.now(),!0),1e3);return()=>clearInterval(e)});function o(e){return!e.duration||e.duration<=0||!e.reset?null:Math.max(0,Math.min(100,100*(1-(e.reset*1e3-W(i))/(e.duration*6e4))))}xn(()=>{Gi.view=W(a)});function s(e){let t=e/100*Math.PI*2;return`M60 60 L60 14 A46 46 0 ${+(e>50)} 1 ${60+46*Math.sin(t)} ${60-46*Math.cos(t)} Z`}var c=Aa(),l=R(c);Z(l,21,()=>r,X,(e,t)=>{var n=ua();let r;var i=z(n,!0);V(e=>{Q(n,`href`,`#/quota/`+W(t)),Q(n,`aria-current`,W(a)===W(t)?`page`:void 0),r=ai(n,1,``,null,r,{active:W(a)===W(t)}),J(i,e)},[()=>W(t)===`pace`?`CONSUMPTION PACE`:W(t)===`zone`?`CONSUMPTION ZONE`:W(t)===`fuel`?`FUEL TANK`:W(t).toUpperCase()]),q(e,n)}),A(l);var u=B(l,2),d=e=>{var t=ka(),n=R(t),r=e=>{q(e,da())};Y(n,e=>{$.data.quotaError&&e(r)});var i=B(n,2),c=z(i),l=B(i,2),u=e=>{var t=ma(),n=L(t),r=z(n),i=B(n,4),a=e=>{q(e,fa())};Y(i,e=>{$.data.credits.length||e(a)}),Z(B(i,2),17,()=>$.data.credits,X,(e,t)=>{var n=pa(),r=L(n),i=z(r),a=z(B(r,2),!0);A(n),V(e=>{J(i,`${(W(t).title||`Quota reset`)??``} // ${W(t).status??``}`),J(a,e)},[()=>W(t).expiryKnown?W(t).expires?`EXPIRES `+Qi(W(t).expires):`Does not expire`:`Expiry information unavailable`]),q(e,n)}),Te(2),A(t),V(()=>J(r,`RESET INVENTORY // ${$.data.creditCount??``} AVAILABLE`)),q(e,t)},d=e=>{var t=Oa(),n=R(t);let r;Z(n,21,()=>$.data.meters,X,(e,t)=>{let n=N(()=>o(W(t))),r=N(()=>W(n)===null?null:W(n)-W(t).used);var i=Ea(),c=L(i),l=z(c,!0),u=B(c,2),d=L(u),f=e=>{var n=ha(),r=R(n),i=z(r),a=z(B(r));V(()=>{J(i,`FREE ${100-W(t).used}%`),J(a,`USED ${W(t).used??``}%`)}),q(e,n)},p=e=>{var n=ha(),r=R(n),i=z(r),a=z(B(r));V(()=>{J(i,`USED ${W(t).used??``}%`),J(a,`FREE ${100-W(t).used}%`)}),q(e,n)};Y(d,e=>{W(a)===`fuel`?e(f):e(p,-1)}),A(u);var m=B(u,2);let h;var g=L(m),_=e=>{var n=va(),r=L(n),i=B(L(r)),a=e=>{q(e,ga())},o=e=>{var n=_a();V(e=>Q(n,`d`,e),[()=>s(W(t).used)]),q(e,n)};Y(i,e=>{W(t).used>=100?e(a):W(t).used>0&&e(o,1)}),A(r),A(n),V(()=>Q(r,`aria-label`,`${W(t).used}% quota used`)),q(e,n)},v=e=>{var r=Mr(),i=R(r),a=e=>{{let r=N(()=>W(t).trail||[]);la(e,{get used(){return W(t).used},get elapsed(){return W(n)},get trail(){return W(r)}})}},o=e=>{q(e,ya())};Y(i,e=>{W(n)===null?e(o,-1):e(a)}),q(e,r)},y=e=>{var t=Mr(),n=R(t),i=e=>{var t=ba(),n=R(t),i=B(L(n),2);let a;A(n);var o=B(n,4),s=L(o),c=z(B(s),!0);A(o),V(e=>{a=si(i,``,a,{left:`${(W(r)+100)/2}%`}),J(s,`${W(r)>=0?`+`:``}${e??``} PP `),J(c,W(r)>=0?`WITHIN PACE`:`USING FASTER THAN TIME`)},[()=>W(r).toFixed(1)]),q(e,t)},a=e=>{q(e,xa())};Y(n,e=>{W(r)===null?e(a,-1):e(i)}),q(e,t)},b=e=>{var r=wa(),i=R(r),o=L(i);let s;A(i);var c=B(i,2),l=e=>{q(e,Sa())};Y(c,e=>{W(a)===`fuel`&&e(l)});var u=B(c,2),d=e=>{var t=Ca(),r=R(t),i=z(r),o=B(r,2),s=L(o);let c;A(o),V(e=>{J(i,`RESET CYCLE // ${e??``}% ELAPSED`),c=si(s,``,c,{width:`${W(a)===`fuel`?100-W(n):W(n)}%`})},[()=>Math.floor(W(n))]),q(e,t)};Y(u,e=>{W(n)!==null&&e(d)}),V(()=>{Q(i,`aria-label`,W(a)===`fuel`?`Fuel remaining`:`Quota used`),Q(i,`aria-valuenow`,W(a)===`fuel`?100-W(t).used:W(t).used),s=si(o,``,s,{width:`${W(a)===`fuel`?100-W(t).used:W(t).used}%`})}),q(e,r)};Y(g,e=>{W(a)===`pie`?e(_):W(a)===`zone`?e(v,1):W(a)===`pace`?e(y,2):e(b,-1)}),A(m);var x=B(m,2),S=z(x),C=B(x,2),w=e=>{var n=Ta(),r=z(n,!0);V(()=>J(r,W(t).details)),q(e,n)};Y(C,e=>{W(t).details&&e(w)}),A(i),V(e=>{J(l,W(t).name),h=ai(m,1,`meter-graphic`,null,h,{"bar-graphic":W(a)===`bars`||W(a)===`fuel`}),J(S,`RESETS // ${e??``}`)},[()=>Qi(W(t).reset)]),q(e,i)}),A(n);var i=B(n,2),c=e=>{q(e,Da())};Y(i,e=>{$.data.meters.length||e(c)}),V(()=>r=ai(n,1,`quota-grid`,null,r,{radial:W(a)===`pie`,zone:W(a)===`zone`})),q(e,t)};Y(l,e=>{W(a)===`resets`?e(u):e(d,-1)}),Te(2),V(e=>J(c,`QUOTA // OBSERVED ${e??``}`),[()=>Qi($.data.quotaAt)]),q(e,t)};Y(u,e=>{$.data&&e(d)}),q(e,c),qe()}var Ma=K(`
`),Na=K(`

`,1);function Pa(e,t){Ke(t,!0);let n=Ni(t,`values`,19,()=>[]),r=Ni(t,`label`,3,`Token activity`),i=Ni(t,`capacity`,3,0),a=N(()=>Math.max(0,...n())),o=N(()=>i()>n().length?[...Array(i()-n().length).fill(0),...n()]:n());var s=Na(),c=R(s),l=z(c),u=B(c,2);Z(u,21,()=>W(o),X,(e,t)=>{var n=Ma();let r;V((e,t)=>{Q(n,`title`,e),r=si(n,``,r,{height:t})},[()=>W(t).toLocaleString(`en-GB`)+` tokens`,()=>`${100*W(t)/Math.max(1,W(a))}%`]),q(e,n)}),A(u),V((e,t)=>{J(l,`SCALE // 0 — ${e??``} TOKENS`),Q(u,`aria-label`,t)},[()=>W(a).toLocaleString(`en-GB`),()=>`${r()}. Peak ${W(a).toLocaleString(`en-GB`)} tokens.`]),q(e,s),qe()}var Fa=K(`

CURRENT PROFILE

MODEL / REASONING LEVEL / SPEED

 

PROPOSED PROFILE

MODEL / REASONING LEVEL / SPEED

 

Applied settings remain after Codexometer closes.

`,1),Ia=K(`

`),La=K(`

 
`,1),Ra=K(`

Command unavailable from this observation. Open Codex to inspect the + request.

`),za=K(`

Session controls temporarily unavailable. Check Codex for current state.

`),Ba=K(`

Checking session controls…

`),Va=K(`

`),Ha=K(`
About browser controls

Controls require a supported live request from a connected shared + app-server session. Local observations alone cannot provide them.

`),Ua=K(` `,1),Wa=K(` `),Ga=K(`Grants permission beyond this one command. Check the scope + carefully.`),Ka=K(``),qa=K(``),Ja=K(``),Ya=K(``),Xa=K(` `,1),Za=K(`
  • `),Qa=K(`
    View fixed choices

    Type one of these choices exactly. Your answer stays masked.

      `),$a=K(` `,1),eo=K(`

      `,1),to=K(``),no=K(`

      `,1),ro=K(`

      `);function io(e,t){Ke(t,!0);let n=[],r=Ni(t,`observedCommand`,3,``),i=Ni(t,`review`,3,``),a=F(null),o=F(Qt([])),s=F(null),c=F(``),l=F(0),u=F(Qt(Date.now())),d=F(!1),f=F(``),p=F(!1),m=F(!1),h=F(``),g=N(()=>$.data?.sessions.find(e=>e.id===t.session)?.status),_=N(()=>!$.connected||!!$.data?.sessionsError),v=N(()=>!W(_)&&!W(m)&&!!W(a)?.id&&W(a).kind===`approval`&&W(h)!==W(a).id),y=N(()=>W(v)?W(a).command:r()),b=N(()=>!!W(c)&&W(u)W(a)?.questions?.length?W(a).questions:[{text:`Follow-up message`,secret:!1,freeText:!0,options:[]}]),S=N(()=>W(a)?.kind===`approval`||W(a)?.kind===`profile`?W(s)!==null:W(o).length===W(x).length&&W(o).every((e,t)=>e.trim().length>0&&(W(x)[t].freeText||W(x)[t].options?.includes(e))));xn(()=>{(W(_)||W(u)>=W(l))&&I(c,``)});let C=new AbortController;Pi(()=>{let e,n=setInterval(()=>{I(u,Date.now(),!0)},1e3);async function r(){try{let e=await ta(`offer`,{session:t.session,...i()?{review:i()}:{}},C.signal);if(C.signal.aborted)return;I(m,!1),W(a)?.id!==e.id&&(I(c,``),I(s,null),I(o,Array(Math.max(1,e.questions?.length||0)).fill(``),!0)),I(a,e,!0),W(p)&&e.id&&e.id!==W(h)&&(I(f,``),I(p,!1))}catch{C.signal.aborted||(I(a,null),I(m,!0),I(c,``))}finally{C.signal.aborted||(e=setTimeout(r,2e3))}}return r(),()=>{C.abort(),clearTimeout(e),clearInterval(n)}});async function w(){if(!W(a)?.id||W(d)||W(_)||!W(S))return;let e=W(a).id;I(d,!0),I(f,``),I(p,!1),I(c,``);try{let n=await ta(`prepare`,{session:t.session,...i()?{review:i()}:{},offer:e,...W(a).kind===`approval`||W(a).kind===`profile`?{choice:W(s)}:{answers:[...W(o)]}},C.signal);W(a)?.id===e&&!C.signal.aborted&&!W(_)&&(I(c,n.confirmation,!0),I(l,Date.parse(n.expires),!0))}catch(e){C.signal.aborted||I(f,e instanceof Error?e.message:`Unable to prepare action.`,!0)}finally{I(d,!1)}}async function T(){if(!W(a)?.id||W(d)||!W(b))return;let e=W(a).id,n=W(a).kind,r=W(c);I(c,``),I(d,!0),I(h,e,!0);try{await ta(`commit`,{session:t.session,...i()?{review:i()}:{},offer:e,confirmation:r},C.signal),I(p,!0),I(f,n===`profile`?`Profile decision completed.`:n===`approval`?`Decision sent.`:`Text sent.`,!0)}catch{I(f,`Outcome uncertain. Check Codex before taking another action; nothing was retried.`)}finally{I(d,!1),I(o,[],!0),I(s,null)}}var ee=ro(),E=L(ee),te=z(E,!0),ne=B(E,2),re=e=>{var t=Fa(),n=R(t),r=z(n),i=B(n,6),o=z(i,!0),s=z(B(i,6),!0);Te(2),V(()=>{J(r,`Your ${W(a).profile.threshold??``}% quota threshold has been reached. Review + the profile for subsequent turns.`),J(o,W(a).profile.current),J(s,W(a).profile.proposed)}),q(e,t)};Y(ne,e=>{W(a)?.profile&&!W(_)&&!W(m)&&e(re)});var ie=B(ne,2),ae=e=>{var t=Ia();let n;var r=z(t,!0);V(()=>{n=ai(t,1,`svelte-1oupzfc`,null,n,{notice:!W(p),sent:W(p)}),J(r,W(f))}),q(e,t)};Y(ie,e=>{W(f)&&e(ae)});var oe=B(ie,2),se=e=>{var t=La(),n=R(t),r=z(n,!0),i=z(B(n,2),!0);V(()=>{J(r,W(v)?`EXACT COMMAND`:`LAST OBSERVED COMMAND`),J(i,W(y))}),q(e,t)},ce=e=>{q(e,Ra())};Y(oe,e=>{W(y)?e(se):i()!==`profile`&&W(g)===`APPROVAL NEEDED`&&!W(p)&&e(ce,1)});var le=B(oe,2),ue=e=>{q(e,za())},de=e=>{q(e,Ba())},fe=e=>{var t=Ua(),n=R(t),r=e=>{var t=Va(),n=z(t,!0);V(e=>J(n,e),[()=>W(g)===`WORKING`?`Codex is working — nothing to respond to.`:[`APPROVAL NEEDED`,`INPUT NEEDED`].includes(W(g)||``)?`Respond in Codex for this request.`:`Nothing needs a response right now.`]),q(e,t)};Y(n,e=>{(W(g)===`WORKING`||!W(p)&&!W(d))&&e(r)});var i=B(n,2),a=e=>{q(e,Ha())},o=N(()=>[`APPROVAL NEEDED`,`INPUT NEEDED`].includes(W(g)||``)&&!W(p));Y(i,e=>{W(o)&&e(a)}),q(e,t)},pe=e=>{var r=no(),f=R(r),p=z(f),m=B(f,2),h=L(m),g=z(h,!0),v=B(h,2),y=e=>{var r=Mr();Z(R(r),17,()=>W(a).choices||[],X,(e,r,a)=>{var o=Ka(),c=L(o);vi(c),c.value=c.__value=a;var l=B(c),u=B(l),d=e=>{var t=Wa(),n=z(t,!0);V(()=>J(n,W(r).detail)),q(e,t)};Y(u,e=>{W(r).detail&&e(d)});var f=B(u,2),p=e=>{q(e,Ga())};Y(f,e=>{W(r).persistent&&e(p)}),A(o),V(()=>{Q(c,`name`,`decision-`+t.session+`-`+i()),J(l,` ${W(r).label??``} `)}),wi(n,[],c,()=>W(s),e=>I(s,e)),q(e,o)}),q(e,r)},C=e=>{var t=Mr();Z(R(t),17,()=>W(x),X,(e,t,n)=>{var r=$a(),i=R(r),a=L(i),s=B(a),c=e=>{var t=qa();vi(t),Si(t,()=>W(o)[n],e=>W(o)[n]=e),q(e,t)},l=e=>{var r=Ya(),i=L(r);i.value=i.__value=``,Z(B(i),17,()=>W(t).options||[],X,(e,t)=>{var n=Ja(),r=z(n,!0),i={};V(()=>{J(r,W(t)),i!==(i=W(t))&&(n.value=(n.__value=i)??``)}),q(e,n)}),A(r),di(r),fi(r,()=>W(o)[n],e=>W(o)[n]=e),q(e,r)},u=e=>{var r=Xa(),i=R(r);ot(i);var a=B(i,2),s=e=>{var n=Va(),r=z(n);V(e=>J(r,`Suggested answers: ${e??``}`),[()=>W(t).options.join(` · `)]),q(e,n)};Y(a,e=>{W(t).options?.length&&e(s)}),Si(i,()=>W(o)[n],e=>W(o)[n]=e),q(e,r)};Y(s,e=>{W(t).secret?e(c):W(t).freeText?e(u,-1):e(l,1)}),A(i);var d=B(i,2),f=e=>{var n=Qa(),r=B(L(n),4);Z(r,21,()=>W(t).options||[],X,(e,t)=>{var n=Za(),r=z(n,!0);V(()=>J(r,W(t))),q(e,n)}),A(r),A(n),q(e,n)};Y(d,e=>{W(t).secret&&!W(t).freeText&&e(f)}),V(()=>J(a,`${W(t).text??``} `)),q(e,r)}),q(e,t)};Y(v,e=>{W(a).kind===`approval`||W(a).kind===`profile`?e(y):e(C,-1)}),A(m);var ee=B(m,2),E=e=>{var t=eo(),n=R(t),r=z(n),i=B(n,2),o=z(i),f=B(i,2);V(e=>{J(r,`Check the target and ${W(a).kind===`profile`?`profile above`:W(a).kind===`approval`?`exact command and permission scope`:`message above`}. This will send to Codex; it may start work using + your quota. Confirmation expires in ${e??``}s.`),i.disabled=W(d)||W(_),J(o,`CONFIRM ${(W(a).kind===`approval`||W(a).kind===`profile`?W(a).choices?.[W(s)]?.label:`SEND`)??``}`)},[()=>Math.max(0,Math.ceil((W(l)-W(u))/1e3))]),G(`click`,i,T),G(`click`,f,()=>{I(c,``)}),q(e,t)},te=e=>{var t=to(),n=z(t,!0);V(()=>{t.disabled=W(d)||W(_)||!W(S),J(n,W(d)?`SENDING…`:`REVIEW BEFORE SENDING`)}),G(`click`,t,w),q(e,t)};Y(ee,e=>{W(b)?e(E):e(te,-1)}),V(()=>{J(p,`TARGET // ${W(a).thread??``} // ${(W(a).directory||`Directory unavailable`)??``}`),m.disabled=W(d)||W(b)||W(_),J(g,W(a).kind===`approval`||W(a).kind===`profile`?`Choose a decision`:`Reply to this session`)}),q(e,r)};Y(le,e=>{W(_)||W(m)?e(ue):W(a)?W(a).id?W(h)!==W(a).id&&e(pe,3):e(fe,2):e(de,1)}),A(ee),V(()=>J(te,i()===`profile`?`QUOTA THRESHOLD`:`SESSION CONTROL // EXPERIMENTAL`)),q(e,ee),qe()}Sr([`click`]);var ao=K(`
      `);function oo(e,t){Ke(t,!0);let n=Ni(t,`active`,3,!1),r=N(()=>[`LAST REPLY`,`LAST ACTIVITY`].includes(t.session.contextKind)&&!!t.session.text.trim()),i=F(!1),a=F(``),o=F(!1);xn(()=>{if(!W(o))return;let e=setTimeout(()=>{I(o,!1)},150);return()=>clearTimeout(e)}),xn(()=>{t.session.text,t.session.status,I(a,``)});async function s(){if(!W(r)||W(i))return;let e=t.session.text;I(i,!0),I(o,!0),I(a,``);try{await navigator.clipboard.writeText(e),t.session.text===e&&I(a,`Copied.`)}catch{t.session.text===e&&I(a,`Clipboard unavailable. Select the visible text and copy it manually.`)}finally{I(i,!1)}}function c(e){!n()||!W(r)||e.defaultPrevented||e.repeat||e.ctrlKey||e.metaKey||e.altKey||e.key.toLowerCase()!==`c`||e.target instanceof Element&&e.target.closest(`input, textarea, select, [contenteditable]`)||(e.preventDefault(),s())}var l=Mr();xr(`keydown`,tn,c);var u=R(l),d=e=>{var t=ao(),n=L(t),r=z(n,!0),c=B(n,2);let l;A(t),V(()=>{J(r,W(a)),c.disabled=W(i),l=ai(c,1,`svelte-543j00`,null,l,{flashed:W(o)})}),G(`click`,c,s),q(e,t)};Y(u,e=>{W(r)&&e(d)}),q(e,l),qe()}Sr([`click`]);var so=K(`

      `),co=K(`

      `),lo=K(`
       
      `),uo=K(`

      Command unavailable from this observation. Open Codex to inspect the + request.

      `),fo=K(`

      `,1),po=K(`

      `),mo=K(`
       
      `,1),ho=K(`
      `),go=K(`

      Session connection or refresh unavailable. Context and telemetry may be + stale.

      `),_o=K(`

      Quota profile checks temporarily unavailable. No automatic update will be + attempted until quota and session settings can be checked.

      `),vo=K(` `),yo=K(` `),bo=K(``),xo=K(`

      Read only — reply or approve in Codex.

      `),So=K(`

      `),Co=K(`
      `),wo=K(`
      `),To=K(`

      This session is no longer in the current observation. Return to sessions.

      `),Eo=K(`

      `),Do=K(` `),Oo=K(`

      `),ko=K(`QUOTA THRESHOLD // REVIEW PROFILE ↗`),Ao=K(` `,1),jo=K(`

      `),Mo=K(`

      TOKEN ACTIVITY // 30 SECOND SAMPLES

      `),No=K(`

      TOKENS

      FULL DETAIL →
      `),Po=K(`

      No locally observed sessions yet. Keep Codex running alongside + Codexometer.

      `),Fo=K(`

      ↑ ↓ SELECT SESSION // ← LESS DETAIL // → MORE DETAIL // ESC BACK

      `,1),Io=K(`

      SESSION TOTALS

      `,1);function Lo(e,t){Ke(t,!0);let n=(e,t=f,n,r)=>{let i=vt(()=>h(n?.(),!0)),a=vt(()=>h(r?.(),!1));var o=mo(),s=R(o),c=e=>{var n=so();let r;var i=z(n,!0);V(e=>{r=ai(n,1,`attention-note`,null,r,{inferred:t().status===`CHECK SESSION`}),J(i,e)},[()=>v(t())]),q(e,n)},u=N(()=>v(t())&&(!W(a)||W(l)||t().status===`CHECK SESSION`));Y(s,e=>{W(u)&&e(c)});var d=B(s,2),p=e=>{var n=co(),r=z(n,!0);V(()=>J(r,t().contextKind||`LAST ACTIVITY`)),q(e,n)};Y(d,e=>{W(i)&&e(p)});var m=B(d,2),g=z(m,!0),_=B(m,2),y=e=>{var n=fo(),r=B(R(n),2),i=z(r,!0),a=B(r,2),o=e=>{var n=lo(),r=z(n,!0);V(()=>J(r,t().command)),q(e,n)},s=e=>{q(e,uo())};Y(a,e=>{t().command?e(o):e(s,-1)}),V(()=>J(i,W(l)||t().status!==`APPROVAL NEEDED`?`LAST OBSERVED COMMAND`:$.data?.control?`COMMAND REQUEST`:`COMMAND TO APPROVE IN CODEX`)),q(e,n)};Y(_,e=>{(!W(a)||!$.data?.control)&&(t().command||t().status===`APPROVAL NEEDED`)&&e(y)});var b=B(_,2),x=e=>{var n=po(),r=z(n);V(()=>J(r,`CONTEXT SOURCE // ${(t().source||`LOCAL`)??``} // OBSERVATION`)),q(e,n)};Y(b,e=>{W(a)||e(x)}),V(()=>J(g,t().text||`No session context available.`)),q(e,o)},r=Ni(t,`params`,19,()=>({})),i=N(()=>$.data?.sessions||[]),a=N(()=>$.data?.control&&$.data.profiles||[]);function o(e){p(e),setTimeout(()=>document.getElementById(`quota-profile-`+e)?.scrollIntoView({block:`nearest`}),100)}xn(()=>{let e=r().id;e&&mr(()=>{Gi.selected=e,Xi(e,2)})});let s=N(()=>W(i).find(e=>e.id===r().id)),c=N(()=>W(i).some(e=>e.id===Gi.selected)?Gi.selected:W(i)[0]?.id),l=N(()=>!$.connected||!!$.data?.sessionsError),u=N(()=>[[`OBSERVED TOKENS`,Zi(W(i).reduce((e,t)=>e+t.tokens,0))],[`LISTED SESSIONS`,Zi(W(i).length)],...[[`WORKING`,`WORKING`],[`AWAITING APPROVAL`,`APPROVAL NEEDED`],[`AWAITING INPUT`,`INPUT NEEDED`],[`CHECK · INFERRED`,`CHECK SESSION`]].map(([e,t])=>[e,W(l)?`—`:Zi(W(i).filter(e=>e.status===t).length)])]),d=N(()=>W(i).filter(e=>[`INPUT NEEDED`,`APPROVAL NEEDED`,`CHECK SESSION`].includes(e.status)));function p(e){Gi.selected=e,dr().then(()=>document.querySelector(`.session-row.selected`)?.scrollIntoView({block:`nearest`}))}function m(e,t){if(p(e),r().id){t<0&&(Xi(e,2),location.hash=`/sessions`);return}let n=Ji(e)+t;n>2?location.hash=`/sessions/`+encodeURIComponent(e):Xi(e,n)}function _(e){let t=e.target;if(!(e.altKey||e.ctrlKey||e.metaKey||e.shiftKey||t.closest(`input, textarea, select, [contenteditable="true"]`))){if(e.key===`Escape`&&r().id)e.preventDefault(),m(r().id,-1);else if([`ArrowLeft`,`ArrowRight`].includes(e.key)&&(r().id||W(c)))e.preventDefault(),m(r().id||W(c),e.key===`ArrowLeft`?-1:1);else if(!r().id&&[`ArrowUp`,`ArrowDown`].includes(e.key)&&W(i).length){e.preventDefault();let t=W(i).findIndex(e=>e.id===W(c));p(W(i)[Math.max(0,Math.min(W(i).length-1,t+(e.key===`ArrowUp`?-1:1)))].id)}}}function v(e){return W(l)?`Last observation only — refresh unavailable. Check Codex for current state.`:e.status===`CHECK SESSION`?`INFERRED INACTIVITY — a quiet session, not a confirmed input or approval request. Local tools may still be running.`:$.data?.control&&[`APPROVAL NEEDED`,`INPUT NEEDED`].includes(e.status)?`Open full detail for supported live controls; otherwise reply in Codex.`:e.status===`APPROVAL NEEDED`?`OBSERVED APPROVAL SIGNAL — approve or decline in Codex.`:e.status===`INPUT NEEDED`?`OBSERVED INPUT SIGNAL — reply in Codex.`:e.status===`TURN COMPLETE`?`OBSERVED TURN COMPLETION — informational, not an approval request.`:``}var y=Io();xr(`keydown`,tn,_);var b=R(y),x=z(B(L(b),2));A(b);var S=B(b,2);let C;Z(S,21,()=>W(u),X,(e,t)=>{var n=N(()=>g(W(t),2));let r=()=>W(n)[0],i=()=>W(n)[1];var a=ho(),o=L(a),s=z(o,!0),c=z(B(o,2),!0);A(a),V(()=>{J(s,r()),J(c,i())}),q(e,a)}),A(S);var w=B(S,2),T=z(w),ee=B(w,2),E=e=>{q(e,go())};Y(ee,e=>{W(l)&&e(E)});var te=B(ee,2),ne=e=>{q(e,_o())};Y(te,e=>{$.data?.profileError&&e(ne)});var re=B(te,2),ie=e=>{var t=bo(),n=L(t);Z(n,17,()=>W(d),X,(e,t)=>{var n=vo();let r;var i=z(n);V(e=>{r=ai(n,1,`button`,null,r,{approval:W(t).status===`APPROVAL NEEDED`}),Q(n,`href`,e),J(i,`${W(t).status??``} // ${(W(t).directory||W(t).id)??``}`)},[()=>`#/sessions/`+encodeURIComponent(W(t).id)]),G(`click`,n,()=>p(W(t).id)),q(e,n)}),Z(B(n,2),17,()=>W(a).filter(e=>e.pending&&W(i).some(t=>t.id===e.session)),X,(e,t)=>{var n=yo(),r=z(n);V((e,t)=>{Q(n,`href`,e),J(r,`QUOTA THRESHOLD // ${t??``}`)},[()=>`#/sessions/`+encodeURIComponent(W(t).session),()=>W(i).find(e=>e.id===W(t).session)?.directory||W(t).session]),G(`click`,n,()=>o(W(t).session)),q(e,n)}),A(t),q(e,t)},ae=N(()=>(W(d).length||W(a).some(e=>e.pending))&&!W(l));Y(re,e=>{W(ae)&&e(ie)});var oe=B(re,2),se=e=>{var t=Mr(),i=R(t),o=e=>{var t=wo(),i=L(t),o=L(i),c=L(o);let u;var d=B(c);A(o);var f=B(o,2);A(i);var m=B(i,2),h=z(m),g=B(m,2),_=L(g),v=L(_);n(v,()=>W(s),()=>!0,()=>!0),A(_);var y=B(_,2),b=e=>{var t=Mr();Wr(R(t),()=>W(s).id,e=>{io(e,{get session(){return W(s).id},get observedCommand(){return W(s).command}})}),q(e,t)},x=e=>{q(e,xo())};Y(y,e=>{$.data?.control?e(b):e(x,-1)}),A(g);var S=B(g,2);Z(S,17,()=>W(a).filter(e=>e.session===W(s).id),e=>e.session,(e,t)=>{var n=Co(),r=L(n),i=e=>{var n=So(),r=z(n,!0);V(()=>J(r,W(t).notice)),q(e,n)};Y(r,e=>{W(t).notice&&e(i)});var a=B(r,2),o=e=>{io(e,{get session(){return W(s).id},review:`profile`})};Y(a,e=>{W(t).pending&&e(o)}),A(n),V(()=>Q(n,`id`,`quota-profile-`+W(s).id)),q(e,n)}),Wr(B(S,2),()=>W(s).id,e=>{oo(e,{get session(){return W(s)},active:!0})}),A(t),V(e=>{u=ai(c,1,`lamp lit`,null,u,{working:W(s).status===`WORKING`&&!W(l)}),J(d,`${(W(l)?`STALE`:W(s).status)??``} // ${W(s).directory??``}`),J(h,`${e??``} TOKENS // ${W(s).id??``} // CONTEXT SOURCE // ${(W(s).source||`LOCAL`)??``}`)},[()=>Zi(W(s).tokens)]),G(`click`,f,()=>{p(r().id),Xi(r().id,2)}),q(e,t)},c=e=>{q(e,To())};Y(i,e=>{W(s)?e(o):e(c,-1)}),q(e,t)},ce=e=>{var t=Fo(),r=R(t),s=B(L(r),2),u=L(s),d=B(u,2);A(s),A(r);var f=B(r,2);Z(f,17,()=>W(i),e=>e.id,(e,t)=>{let r=N(()=>Ji(W(t).id));var i=No();let s;var u=L(i),d=L(u),f=L(d);let h;var g=B(f,1,!0);A(d);var _=B(d,2),v=z(_,!0),y=B(_,2),b=L(y);Te(),A(y);var x=B(y,2),S=z(x),C=B(x,2),w=z(C),T=B(C,2),ee=L(T),E=B(ee,2),te=z(E,!0),ne=B(E,2);A(T);var re=B(T,2),ie=B(re,2),ae=e=>{var n=Eo(),r=z(n,!0);V(()=>J(r,W(t).status===`CHECK SESSION`?`INFERRED INACTIVITY`:$.data?.control?`OPEN FULL DETAIL OR REPLY IN CODEX`:W(t).status===`APPROVAL NEEDED`?`APPROVE OR DECLINE IN CODEX`:`REPLY IN CODEX`)),q(e,n)},oe=N(()=>!W(l)&&[`APPROVAL NEEDED`,`INPUT NEEDED`,`CHECK SESSION`].includes(W(t).status));Y(ie,e=>{W(oe)&&e(ae)}),A(u);var se=B(u,2),ce=e=>{var r=jo(),i=L(r),s=L(i),u=z(s,!0),d=B(s,2),f=e=>{var n=Do(),r=z(n,!0);V(e=>{Q(n,`href`,e),J(r,$.data?.control?`REVIEW IN FULL DETAIL ↗`:`APPROVAL IN CODEX ↗`)},[()=>`#/sessions/`+encodeURIComponent(W(t).id)]),q(e,n)};Y(d,e=>{!W(l)&&W(t).status===`APPROVAL NEEDED`&&e(f)}),A(i);var p=B(i,2);n(p,()=>W(t),()=>!1);var m=B(p,2);Z(m,17,()=>W(a).filter(e=>e.session===W(t).id),X,(e,n)=>{var r=Ao(),i=R(r),a=e=>{var t=Oo(),r=z(t,!0);V(()=>J(r,W(n).notice)),q(e,t)};Y(i,e=>{W(n).notice&&e(a)});var s=B(i,2),c=e=>{var n=ko();V(e=>Q(n,`href`,e),[()=>`#/sessions/`+encodeURIComponent(W(t).id)]),G(`click`,n,()=>o(W(t).id)),q(e,n)};Y(s,e=>{W(n).pending&&e(c)}),q(e,r)});var h=B(m,2);{let e=N(()=>W(c)===W(t).id);oo(h,{get session(){return W(t)},get active(){return W(e)}})}A(r),V(()=>J(u,W(t).contextKind||`LAST ACTIVITY`)),q(e,r)};Y(se,e=>{W(r)>0&&e(ce)});var le=B(se,2),ue=e=>{var n=Mo(),r=B(L(n),2);{let e=N(()=>(W(t).samples||[]).map(e=>e.tokens));Pa(r,{get values(){return W(e)},capacity:120})}var i=z(B(r,2));A(n),V(()=>J(i,`LAST ${(W(t).samples?.length||0)??``} SAMPLES // AUTO SCALE`)),q(e,n)};Y(le,e=>{W(r)<2&&e(ue)}),A(i),V((e,n,a)=>{s=ai(i,1,`session-row`,null,s,{selected:W(c)===W(t).id,wide:W(r)===2,split:W(r)===1}),Q(i,`aria-label`,`Session `+(W(t).directory||W(t).id)),h=ai(f,1,`lamp lit`,null,h,{working:W(t).status===`WORKING`&&!W(l)}),J(g,W(l)?`STALE`:W(t).status),Q(_,`aria-pressed`,W(c)===W(t).id),J(v,W(t).directory||W(t).id),J(b,`${e??``} `),J(S,`${W(t).agents??``} LINKED AGENTS`),J(w,`ACTIVE // ${n??``}`),ee.disabled=W(r)===0,Q(E,`aria-expanded`,W(r)>0),J(te,W(r)?`HIDE DETAIL`:`SHOW DETAIL`),Q(re,`href`,a)},[()=>Zi(W(t).tokens),()=>Qi(W(t).activity),()=>`#/sessions/`+encodeURIComponent(W(t).id)]),G(`click`,_,()=>p(W(t).id)),G(`click`,ee,()=>m(W(t).id,-1)),G(`click`,E,()=>{p(W(t).id),Xi(W(t).id,+!W(r))}),G(`click`,ne,()=>m(W(t).id,1)),G(`click`,re,()=>p(W(t).id)),q(e,i)});var h=B(f,2),g=e=>{q(e,Po())};Y(h,e=>{W(i).length||e(g)}),G(`click`,u,()=>Yi(1)),G(`click`,d,()=>Yi(0)),q(e,t)};Y(oe,e=>{r().id?e(se):e(ce,-1)}),V(e=>{J(x,`OBSERVED ${e??``}`),C=ai(S,1,`session-totals`,null,C,{stale:W(l)}),J(T,`${W(l)?`LAST KNOWN TOTALS — live state counts unavailable. `:``}Tokens + observed since this server started for currently listed sessions; linked + agents are already included. Totals can decrease when a session leaves the + list. History samples every 30 seconds. Not account-wide totals.`)},[()=>Qi($.data?.sessionsAt)]),q(e,y),qe()}Sr([`click`]);var Ro=864e5;function zo(e,t){let n=new Date(e);n.setUTCDate(1),n.setUTCMonth(n.getUTCMonth()+t);let r=new Date(Date.UTC(n.getUTCFullYear(),n.getUTCMonth()+1,0)).getUTCDate();return n.setUTCDate(Math.min(e.getUTCDate(),r)),n}function Bo(e,t,n){let r=new Date(Date.UTC(e.getUTCFullYear(),e.getUTCMonth(),e.getUTCDate()));for(let e=0;;e++){let i=zo(r,-t),a=new Date(i.getTime()+Ro);if(e===n)return{start:a,end:r};r=i}}var Vo=K(`

      History refresh failed. Any displayed history is the last successful + observation.

      `),Ho=K(``),Uo=K(`
      `),Wo=K(`

      LESS ░ ▒ ▓ █ MORE // HOVER FOR DATE AND TOKENS

      `,1),Go=K(`
      `,1),Ko=K(` `),qo=K(`

      LIFETIME TOKENS

      PEAK DAY

      CURRENT STREAK

      DAYS

      Accessible data table
      Date (UTC)Tokens

      `,1),Jo=K(`

      History unavailable or awaiting a matching account observation. Missing + history is not treated as zero usage.

      `),Yo=K(`

      USAGE // ACCOUNT HISTORY

      Account-wide history reported by Codex, not the local Sessions counter. Dates + use UTC. Historical resets are not provided by this data.

      `,1);function Xo(e,t){Ke(t,!0);let n=F(`daily`),r=F(12),i=F(0),a=N(()=>{let e=$.data?.usage?.dailyUsageBuckets;if(!e)return[];let{start:t,end:n}=Bo(new Date,W(r),W(i)),a=new Map;for(let t of e)!Number.isFinite(t.tokens)||t.tokens<0||a.set(t.startDate,(a.get(t.startDate)||0)+t.tokens);let o=[];for(let e=t;e<=n;e=new Date(e.getTime()+864e5)){let t=e.toISOString().slice(0,10);o.push({date:t,tokens:a.get(t)||0})}return o}),o=N(()=>Math.max(1,...W(a).map(e=>e.tokens))),s=N(()=>W(a).length?new Date(W(a)[0].date+`T00:00:00Z`).getUTCDay():0),c=N(()=>{if(W(n)===`monthly`){let e=new Map;for(let t of W(a))e.set(t.date.slice(0,7),(e.get(t.date.slice(0,7))||0)+t.tokens);return[...e].map(([e,t])=>({date:e,tokens:t}))}let e=0;return W(a).map(t=>({date:t.date,tokens:e+=t.tokens}))});var l=Yo(),u=B(R(l),4),d=L(u),f=B(L(d)),p=L(f);p.value=p.__value=`daily`;var m=B(p);m.value=m.__value=`monthly`;var h=B(m);h.value=h.__value=`cumulative`,A(f),di(f),A(d);var g=B(d),_=B(L(g)),v=L(_);v.value=v.__value=6;var y=B(v);y.value=y.__value=12,A(_),di(_),A(g);var b=B(g),x=B(b);A(u);var S=B(u,2),C=e=>{q(e,Vo())};Y(S,e=>{$.data?.usageError&&e(C)});var w=B(S,2),T=e=>{var t=qo(),r=R(t),i=L(r),l=z(B(L(i),2),!0);A(i);var u=B(i,2),d=z(B(L(u),2),!0);A(u);var f=B(u,2),p=B(L(f),2),m=L(p);Te(),A(p),A(f),A(r);var h=B(r,2),g=L(h),_=z(g),v=B(g,2),y=e=>{var t=Wo(),n=R(t),r=L(n),i=L(r);Z(i,17,()=>Array(W(s)),X,(e,t)=>{q(e,Ho())}),Z(B(i,2),17,()=>W(a),X,(e,t)=>{var n=Uo();let r,i;V(e=>{r=ai(n,1,`heat-cell`,null,r,{zero:W(t).tokens===0}),Q(n,`title`,e),i=si(n,``,i,{opacity:W(t).tokens?.25+.75*W(t).tokens/W(o):1})},[()=>`${W(t).date}: ${Zi(W(t).tokens)} tokens`]),q(e,n)}),A(r),A(n),Te(2),q(e,t)},b=e=>{var t=Go(),r=R(t);{let e=N(()=>W(c).map(e=>e.tokens)),t=N(()=>W(n)+` usage`);Pa(r,{get values(){return W(e)},get label(){return W(t)}})}var i=B(r,2),a=L(i),o=z(a,!0),s=z(B(a),!0);A(i),V(e=>{J(o,W(c)[0]?.date),J(s,e)},[()=>W(c).at(-1)?.date]),q(e,t)};Y(v,e=>{W(n)===`daily`?e(y):e(b,-1)});var x=B(v,2),S=B(L(x),2),C=L(S),w=B(L(C));Z(w,21,()=>W(n)===`daily`?W(a):W(c),X,(e,t)=>{var n=Ko(),r=L(n),i=z(r,!0),a=z(B(r),!0);A(n),V(e=>{J(i,W(t).date),J(a,e)},[()=>Zi(W(t).tokens)]),q(e,n)}),A(w),A(C),A(S),A(x),A(h);var T=z(B(h,2));V((e,t,n,r,i)=>{J(l,e),J(d,t),J(m,`${n??``} `),J(_,`${W(a)[0]?.date??``} — ${r??``}`),J(T,`OBSERVED ${i??``} // CUMULATIVE IS FOR THE SELECTED PERIOD`)},[()=>Zi($.data.usage.summary.lifetimeTokens),()=>Zi($.data.usage.summary.peakDailyTokens),()=>Zi($.data.usage.summary.currentStreakDays),()=>W(a).at(-1)?.date,()=>Qi($.data.usageAt)]),q(e,t)},ee=e=>{q(e,Jo())};Y(w,e=>{$.data?.usage&&$.data.usage.dailyUsageBuckets!==null?e(T):e(ee,-1)}),V(()=>x.disabled=W(i)===0),fi(f,()=>W(n),e=>I(n,e)),G(`change`,_,()=>I(i,0)),fi(_,()=>W(r),e=>I(r,e)),G(`click`,b,()=>Yt(i)),G(`click`,x,()=>Yt(i,-1)),q(e,l),qe()}Sr([`change`,`click`]),We();var Zo=K(`

      Page not found

      Return to Quota

      `,1);function Qo(e){var t=Zo();Te(2),q(e,t)}var $o=K(` `),es=K(`

      `),ts=K(`

      Connecting to your local Codexometer…

      `),ns=K(``),rs=K(`
      CODEXOMETER

      Your quota. Your sessions. Your command centre.

      `);function is(e,t){Ke(t,!0);let n={"/":ja,"/quota/:view?":ja,"/sessions/:id?":Lo,"/usage":Xo,"*":Qo},r={quota:/^(?:\/|\/quota(?:\/[^/]+)?\/?)$/,sessions:/^\/sessions(?:\/[^/]+)?\/?$/,usage:/^\/usage\/?$/},i=F(`hacker`),a=[`hacker`,`rust`,`blue-steel`,`ultraviolet`,`nightshade`];xn(()=>{Ki()}),xn(()=>{if($.data)for(let[e,t]of Object.entries(r))t.test(Li.location)&&(Gi.tab=e)}),Pi(()=>{try{let e=localStorage.getItem(`codexometer.web.theme`);e&&a.includes(e)&&I(i,e,!0)}catch{}return na()});function o(){try{localStorage.setItem(`codexometer.web.theme`,W(i))}catch{}}var s=rs(),c=L(s),l=B(L(c),2),u=L(l);let d;var f=B(u,1,!0),p=z(B(f));A(l),A(c);var m=B(c,2);Z(m,21,()=>Object.entries(r),X,(e,t)=>{var n=N(()=>g(W(t),2));let r=()=>W(n)[0],i=()=>W(n)[1],a=N(()=>i().test(Li.location));var o=$o();let s;var c=z(o,!0);V(e=>{Q(o,`href`,r()===`quota`?`#/quota/`+Gi.view:`#/`+r()),Q(o,`aria-current`,W(a)?`page`:void 0),s=ai(o,1,``,null,s,{active:W(a)}),J(c,e)},[()=>r().toUpperCase()]),q(e,o)}),A(m);var h=B(m,2),_=L(h),v=e=>{var t=es(),n=z(t,!0);V(()=>J(n,$.error)),q(e,t)};Y(_,e=>{$.error&&e(v)});var y=B(_,2),b=e=>{Bi(e,{get routes(){return n}})},x=e=>{q(e,ts())};Y(y,e=>{$.data?e(b):$.error||e(x,1)}),A(h);var S=B(h,2),C=L(S),w=z(C),T=B(C,2),ee=z(T,!0),E=B(T,2),te=B(L(E));Z(te,21,()=>a,X,(e,t)=>{var n=ns(),r=z(n,!0),i={};V(e=>{J(r,e),i!==(i=W(t))&&(n.value=(n.__value=i)??``)},[()=>W(t).replace(`-`,` `).toUpperCase()]),q(e,n)}),A(te),di(te),A(E),A(S),A(s),V(()=>{Q(s,`data-theme`,W(i)),d=ai(u,1,`lamp`,null,d,{lit:$.connected}),J(f,$.connected?`CONNECTED`:`OFFLINE`),J(p,`EXPERIMENTAL // ${$.data?.control?`SESSION CONTROL`:`READ ONLY`}`),J(w,`v${($.data?.version||`…`)??``} // LOCAL ONLY`),J(ee,$.data?.control?`Session control enabled. Stop the server to revoke access.`:`No actions can be sent from this preview.`)}),G(`change`,te,o),fi(te,()=>W(i),e=>I(i,e)),q(e,s),qe()}Sr([`change`]),Rr(is,{target:document.getElementById(`app`)}); \ No newline at end of file diff --git a/internal/web/dist/assets/index-CK6j1LaO.js b/internal/web/dist/assets/index-CK6j1LaO.js deleted file mode 100644 index 29a124d..0000000 --- a/internal/web/dist/assets/index-CK6j1LaO.js +++ /dev/null @@ -1,23 +0,0 @@ -(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){for(var t=0;t{e=n,t=r}),resolve:e,reject:t}}function h(e,t,n=!1){return e===void 0?n?t():t:e}function g(e,t){if(Array.isArray(e))return e;if(t===void 0||!(Symbol.iterator in e))return Array.from(e);let n=[];for(let r of e)if(n.push(r),n.length===t)break;return n}var _=1024,v=2048,y=4096,b=8192,x=16384,S=32768,C=1<<25,w=65536,T=1<<19,ee=1<<20,te=1<<25,E=65536,ne=1<<21,re=1<<22,ie=1<<23,ae=Symbol(`$state`),oe=Symbol(`component`),se=Symbol(`legacy props`),ce=Symbol(``),le=Symbol(`attributes`),ue=Symbol(`class`),de=Symbol(`style`),fe=Symbol(`text`),pe=Symbol(`form reset`),me=new class extends Error{name=`StaleReactionError`;message="The reaction that called `getAbortSignal()` was re-run or destroyed"},he=!!globalThis.document?.contentType&&globalThis.document.contentType.includes(`xml`),ge={},D=Symbol(`uninitialized`),_e=`http://www.w3.org/1999/xhtml`;function ve(){console.warn(`https://svelte.dev/e/derived_inert`)}function ye(e){console.warn(`https://svelte.dev/e/hydration_mismatch`)}function be(){console.warn(`https://svelte.dev/e/select_multiple_invalid_value`)}function xe(){console.warn(`https://svelte.dev/e/svelte_boundary_reset_noop`)}var O=!1;function Se(e){O=e}var k;function Ce(e){if(e===null)throw ye(),ge;return k=e}function we(){return Ce(ln(k))}function A(e){if(O){if(ln(k)!==null)throw ye(),ge;k=e}}function Te(e=1){if(O){for(var t=e,n=k;t--;)n=ln(n);k=n}}function Ee(e=!0){for(var t=0,n=k;;){if(n.nodeType===8){var r=n.data;if(r===`]`){if(t===0)return n;--t}else(r===`[`||r===`[!`||r[0]===`[`&&!isNaN(Number(r.slice(1))))&&(t+=1)}var i=ln(n);e&&n.remove(),n=i}}function De(e){if(!e||e.nodeType!==8)throw ye(),ge;return e.data}function Oe(e){return e===this.v}function ke(e,t){return e==e?e!==t||typeof e==`object`&&!!e||typeof e==`function`:t==t}function Ae(e){return!ke(e,this.v)}function je(e){throw Error(`https://svelte.dev/e/lifecycle_outside_component`)}function Me(){throw Error(`https://svelte.dev/e/async_derived_orphan`)}function Ne(e,t,n){throw Error(`https://svelte.dev/e/each_key_duplicate`)}function Pe(e){throw Error(`https://svelte.dev/e/effect_in_teardown`)}function Fe(){throw Error(`https://svelte.dev/e/effect_in_unowned_derived`)}function Ie(e){throw Error(`https://svelte.dev/e/effect_orphan`)}function Le(){throw Error(`https://svelte.dev/e/effect_update_depth_exceeded`)}function Re(e){throw Error(`https://svelte.dev/e/props_invalid_value`)}function ze(){throw Error(`https://svelte.dev/e/state_descriptors_fixed`)}function Be(){throw Error(`https://svelte.dev/e/state_prototype_fixed`)}function Ve(){throw Error(`https://svelte.dev/e/state_unsafe_mutation`)}function He(){throw Error(`https://svelte.dev/e/svelte_boundary_reset_onerror`)}var Ue=!1;function We(){Ue=!0}var j=null;function Ge(e){j=e}function Ke(e,t=!1,n){j={p:j,i:!1,c:null,e:null,s:e,x:null,r:U,l:Ue&&!t?{s:null,u:null,$:[]}:null}}function qe(e){var t=j,n=t.e;if(n!==null){t.e=null;for(var r of n)Sn(r)}return e!==void 0&&(t.x=e),t.i=!0,j=t.p,Je(e)}function Je(e={}){return i(e,oe,{value:!0}),e}function Ye(){return!Ue||j!==null&&j.l===null}var Xe=[];function Ze(){var e=Xe;Xe=[],p(e)}function Qe(e){if(Xe.length===0&&!Ot){var t=Xe;queueMicrotask(()=>{t===Xe&&Ze()})}Xe.push(e)}function $e(){for(;Xe.length>0;)Ze()}var et=~(v|y|_);function M(e,t){e.f=e.f&et|t}function tt(e){e.f&512||e.deps===null?M(e,_):M(e,y)}function nt(e){if(e!==null)for(let t of e)t.f&2&&t.f&65536&&(t.f^=E,nt(t.deps))}function rt(e,t,n){e.f&2048?t.add(e):e.f&4096&&n.add(e),nt(e.deps),M(e,_)}var it=!1;function at(e){var t=it;try{return it=!1,[e(),it]}finally{it=t}}function ot(e){O&&cn(e)!==null&&un(e)}var st=!1;function ct(){st||(st=!0,document.addEventListener(`reset`,e=>{Promise.resolve().then(()=>{if(!e.defaultPrevented)for(let t of e.target.elements)t[pe]?.()})},{capture:!0}))}function lt(e){var t=H,n=U;Gn(null),Kn(null);try{return e()}finally{Gn(t),Kn(n)}}function ut(e,t,n,r=n){e.addEventListener(t,()=>lt(n));let i=e[pe];e[pe]=i?()=>{i(),r(!0)}:()=>r(!0),ct()}function dt(e,t,n,r){let i=Ye()?ht:vt;var a=e.filter(e=>!e.settled),o=t.map(i);if(n.length===0&&a.length===0){r(o);return}var s=U,c=ft(),l=a.length===1?a[0].promise:a.length>1?Promise.all(a.map(e=>e.promise)):null;function u(e){if(!(s.f&16384)){c();try{r([...o,...e])}catch(e){hn(e,s)}pt()}}var d=mt();if(n.length===0){l.then(()=>u([])).finally(d);return}function f(){Promise.all(n.map(e=>_t(e))).then(u).catch(e=>hn(e,s)).finally(d)}l?l.then(()=>{c(),f(),pt()}):f()}function ft(){var e=U,t=H,n=j,r=P;return function(i=!0){Kn(e),Gn(t),Ge(n),i&&!(e.f&16384)&&(r?.activate(),r?.apply())}}function pt(e=!0){Kn(null),Gn(null),Ge(null),e&&P?.deactivate()}function mt(){var e=U,t=e.b,n=P,r=!!t?.is_rendered();return t?.update_pending_count(1,n),n.increment(r,e),()=>{t?.update_pending_count(-1,n),n.decrement(r,e)}}function ht(e){var t=2|v;return U!==null&&(U.f|=T),{ctx:j,deps:null,effects:null,equals:Oe,f:t,fn:e,reactions:null,rv:0,v:D,wv:0,parent:U,ac:null}}var gt=Symbol(`obsolete`);function _t(e,t,n){let r=U;r===null&&Me();var i=void 0,a=Gt(D),o=!H,s=new Set;return Tn(()=>{var t=U,n=m();i=n.promise;try{Promise.resolve(e()).then(n.resolve,e=>{e!==me&&n.reject(e)}).finally(pt)}catch(e){n.reject(e),pt()}var c=P;if(o){if(t.f&32768)var l=mt();if(r.b?.is_rendered())c.async_deriveds.get(t)?.reject(gt);else for(let e of s.values())e.reject(gt);s.add(n),c.async_deriveds.set(t,n)}let u=(e,t=void 0)=>{l?.(),s.delete(n),t!==gt&&(c.activate(),t?(a.f|=ie,qt(a,t)):(a.f&8388608&&(a.f^=ie),qt(a,e)),c.deactivate())};n.promise.then(u,e=>u(null,e||`unknown`))}),bn(()=>{for(let e of s)e.reject(gt)}),new Promise(e=>{function t(n){function r(){n===i?e(a):t(i)}n.then(r,r)}t(i)})}function N(e){let t=ht(e);return Jn(t),t}function vt(e){let t=ht(e);return t.equals=Ae,t}function yt(e){var t=e.effects;if(t!==null){e.effects=null;for(var n=0;n{t.ac.abort(me),t.ac=null}),t.fn!==null&&(t.teardown=f),lr(t,0),An(t))}function Ct(e){if(e.effects!==null)for(let t of e.effects)t.teardown&&t.fn!==null&&ur(t)}var wt=null,P=null,Tt=null,Et=null,Dt=null,Ot=!1,kt=!1,At=null,jt=null,Mt=0,Nt=1,Pt=class e{id=Nt++;#e=!1;linked=!0;#t=null;#n=null;async_deriveds=new Map;current=new Map;previous=new Map;#r=new Set;#i=new Set;#a=0;#o=new Map;#s=null;#c=[];#l=[];#u=new Set;#d=new Set;#f=new Map;#p=new Set;is_fork=!1;#m=!1;constructor(){wt===null?wt=this:(wt.#n=this,this.#t=wt),wt=this}#h(){if(this.is_fork)return!0;for(let n of this.#o.keys()){for(var e=n,t=!1;e.parent!==null;){if(this.#f.has(e)){t=!0;break}e=e.parent}if(!t)return!0}return!1}skip_effect(e){this.#f.has(e)||this.#f.set(e,{d:[],m:[]}),this.#p.delete(e)}unskip_effect(e,t=e=>this.schedule(e)){var n=this.#f.get(e);if(n){this.#f.delete(e);for(var r of n.d)M(r,v),t(r);for(r of n.m)M(r,y),t(r)}this.#p.add(e)}#g(){this.#e=!0,Mt++>1e3&&(this.#x(),It());for(let e of this.#u)this.#d.delete(e),M(e,v),this.schedule(e);for(let e of this.#d)M(e,y),this.schedule(e);let t=this.#c;this.#c=[],this.apply();var n=At=[],r=[],i=jt=[];for(let e of t)try{this.#_(e,n,r)}catch(t){throw Vt(e),this.#h()||this.discard(),t}if(P=null,i.length>0){var a=e.ensure();for(let e of i)a.schedule(e)}if(At=null,jt=null,this.#h()){this.#b(r),this.#b(n);for(let[e,t]of this.#f)Bt(e,t);i.length>0&&P.#g();return}let o=this.#v();if(o){this.#b(r),this.#b(n),o.#y(this);return}this.#u.clear(),this.#d.clear();for(let e of this.#r)e(this);this.#r.clear(),Tt=this,Rt(r),Rt(n),Tt=null,this.#s?.resolve();var s=P;if(this.#a===0&&(this.#c.length===0||s!==null)&&this.#x(),this.#c.length>0){if(s!==null){let e=s;e.#c.push(...this.#c.filter(t=>!e.#c.includes(t)))}else s=this}s!==null&&(Ut.clear(),s.#g())}#_(e,t,n){e.f^=_;for(var r=e.first;r!==null;){var i=r.f,a=!!(i&96);if(!(a&&i&1024||i&8192||this.#f.has(r))&&r.fn!==null){a?r.f^=_:i&4?t.push(r):ir(r)&&(i&16&&this.#d.add(r),ur(r));var o=r.first;if(o!==null){r=o;continue}}for(;r!==null;){var s=r.next;if(s!==null){r=s;break}r=r.parent}}}#v(){for(var e=this.#t;e!==null;){if(!e.is_fork){for(let[t,[,n]]of this.current)if(e.current.has(t)&&!n)return e}e=e.#t}return null}#y(e){for(let[t,n]of e.current)!this.previous.has(t)&&e.previous.has(t)&&this.previous.set(t,e.previous.get(t)),this.current.set(t,n);for(let[t,n]of e.async_deriveds){let e=this.async_deriveds.get(t);e&&n.promise.then(e.resolve).catch(e.reject)}e.async_deriveds.clear(),this.transfer_effects(e.#u,e.#d);let t=e=>{var n=e.reactions;if(n!==null&&!(e.f&2&&!(e.f&6144)))for(let e of n){var r=e.f;if(r&2)t(e);else{var i=e;r&4194320&&!this.async_deriveds.has(i)&&(this.#d.delete(i),M(i,v),this.schedule(i))}}};for(let e of this.current.keys())t(e);this.oncommit(()=>e.discard()),e.#x(),P=this,this.#g()}#b(e){for(var t=0;t{this.#m=!1,this.linked&&this.flush()}))}transfer_effects(e,t){for(let t of e)this.#u.add(t);for(let e of t)this.#d.add(e);e.clear(),t.clear()}oncommit(e){this.#r.add(e)}ondiscard(e){this.#i.add(e)}settled(){return(this.#s??=m()).promise}static ensure(){if(P===null){let t=P=new e;!kt&&!Ot&&Qe(()=>{t.#e||t.flush()})}return P}apply(){Et=null}schedule(e){if(Dt=e,e.b?.is_pending&&e.f&16777228&&!(e.f&32768)){e.b.defer_effect(e);return}for(var t=e;t.parent!==null;){t=t.parent;var n=t.f;if(At!==null&&t===U&&(H===null||!(H.f&2)))return;if(n&96){if(!(n&1024))return;t.f^=_}}this.#c.push(t)}#x(){if(this.linked){var e=this.#t,t=this.#n;e===null||(e.#n=t),t===null?wt=e:t.#t=e,this.linked=!1}}};function Ft(e){var t=Ot;Ot=!0;try{var n;for(e&&(P!==null&&!P.is_fork&&P.flush(),n=e());;){if($e(),P===null)return n;P.flush()}}finally{Ot=t}}function It(){try{Le()}catch(e){hn(e,Dt)}}var Lt=null;function Rt(e){var t=e.length;if(t!==0){for(var n=0;n0)){Ut.clear();for(let e of Lt){if(e.f&24576)continue;let t=[e],n=e.parent;for(;n!==null;)Lt.has(n)&&(Lt.delete(n),t.push(n)),n=n.parent;for(let e=t.length-1;e>=0;e--){let n=t[e];n.f&24576||ur(n)}}Lt.clear()}}Lt=null}}function zt(e){P.schedule(e)}function Bt(e,t){if(!(e.f&32&&e.f&1024)){e.f&2048?t.d.push(e):e.f&4096&&t.m.push(e),M(e,_);for(var n=e.first;n!==null;)Bt(n,t),n=n.next}}function Vt(e){M(e,_);for(var t=e.first;t!==null;)Vt(t),t=t.next}var Ht=new Set,Ut=new Map,Wt=!1;function Gt(e,t){return{f:0,v:e,reactions:null,equals:Oe,rv:0,wv:0}}function F(e,t){let n=Gt(e,t);return Jn(n),n}function Kt(e,t=!1,n=!0){let r=Gt(e);return t||(r.equals=Ae),Ue&&n&&j!==null&&j.l!==null&&(j.l.s??=[]).push(r),r}function I(e,t,n=!1){return H!==null&&(!Wn||H.f&131072)&&Ye()&&H.f&4325394&&(qn===null||!qn.has(e))&&Ve(),qt(e,n?Qt(t):t,jt)}function qt(e,t,n=null){if(!e.equals(t)){Hn?Ut.set(e,t):Ut.has(e)||Ut.set(e,e.v);var r=Pt.ensure();if(r.capture(e,t),e.f&2){let t=e;e.f&2048&&bt(t),Et===null&&tt(t)}e.wv=rr(),Zt(e,v,n),Ye()&&U!==null&&U.f&1024&&!(U.f&96)&&(Zn===null?Qn([e]):Zn.push(e)),!r.is_fork&&Ht.size>0&&!Wt&&Jt()}return t}function Jt(){Wt=!1;for(let e of Ht){e.f&1024&&M(e,y);let t;try{t=ir(e)}catch{t=!0}t&&ur(e)}Ht.clear()}function Yt(e,t=1){var n=W(e),r=t===1?n++:n--;return I(e,n),r}function Xt(e){I(e,e.v+1)}function Zt(e,t,n){var r=e.reactions;if(r!==null)for(var i=Ye(),a=r.length,o=0;o{if(tr===d)return e();var t=H,n=tr;Gn(null),nr(d);var r=e();return Gn(t),nr(n),r};return i&&r.set(`length`,F(t.length,u)),new Proxy(t,{defineProperty(e,t,n){(!(`value`in n)||n.configurable===!1||n.enumerable===!1||n.writable===!1)&&ze();var i=r.get(t);return i===void 0?f(()=>{var e=F(n.value,u);return r.set(t,e),e}):I(i,n.value,!0),!0},deleteProperty(e,t){var n=r.get(t);if(n===void 0){if(t in e){let e=f(()=>F(D,u));r.set(t,e),Xt(o)}}else I(n,D),Xt(o);return!0},get(e,n,i){if(n===ae)return t;var o=r.get(n),s=n in e;if(o===void 0&&(!s||a(e,n)?.writable)&&(o=f(()=>F(Qt(s?e[n]:D),u)),r.set(n,o)),o!==void 0){var c=W(o);return c===D?void 0:c}return Reflect.get(e,n,i)},getOwnPropertyDescriptor(e,t){var n=Reflect.getOwnPropertyDescriptor(e,t);if(n&&`value`in n){var i=r.get(t);i&&(n.value=W(i))}else if(n===void 0){var a=r.get(t),o=a?.v;if(a!==void 0&&o!==D)return{enumerable:!0,configurable:!0,value:o,writable:!0}}return n},has(e,t){if(t===ae)return!0;var n=r.get(t),i=n!==void 0&&n.v!==D||Reflect.has(e,t);return(n!==void 0||U!==null&&(!i||a(e,t)?.writable))&&(n===void 0&&(n=f(()=>F(i?Qt(e[t]):D,u)),r.set(t,n)),W(n)===D)?!1:i},set(e,t,n,s){var c=r.get(t),l=t in e;if(i&&t===`length`)for(var d=n;dF(D,u)),r.set(d+``,p)):I(p,D)}if(c===void 0)(!l||a(e,t)?.writable)&&(c=f(()=>F(void 0,u)),I(c,Qt(n)),r.set(t,c));else{l=c.v!==D;var m=f(()=>Qt(n));I(c,m)}var h=Reflect.getOwnPropertyDescriptor(e,t);if(h?.set&&h.set.call(s,n),!l){if(i&&typeof t==`string`){var g=r.get(`length`),_=Number(t);Number.isInteger(_)&&_>=g.v&&I(g,_+1)}Xt(o)}return!0},ownKeys(e){W(o);var t=Reflect.ownKeys(e).filter(e=>{var t=r.get(e);return t===void 0||t.v!==D});for(var[n,i]of r)i.v!==D&&!(n in e)&&t.push(n);return t},setPrototypeOf(){Be()}})}function $t(e){try{if(typeof e==`object`&&e&&ae in e)return e[ae]}catch{}return e}function en(e,t){return Object.is($t(e),$t(t))}var tn,nn,rn,an;function on(){if(tn===void 0){tn=window,nn=/Firefox/.test(navigator.userAgent);var e=Element.prototype,t=Node.prototype,n=Text.prototype;rn=a(t,`firstChild`).get,an=a(t,`nextSibling`).get,u(e)&&(e[ue]=void 0,e[le]=null,e[de]=void 0,e.__e=void 0),u(n)&&(n[fe]=void 0)}}function sn(e=``){return document.createTextNode(e)}function cn(e){return rn.call(e)}function ln(e){return an.call(e)}function L(e,t){if(!O)return cn(e);var n=cn(k);if(n===null)n=k.appendChild(sn());else if(t&&n.nodeType!==3){var r=sn();return n?.before(r),Ce(r),r}return t&&pn(n),Ce(n),n}function R(e,t=!1){if(!O){var n=cn(e);return n instanceof Comment&&n.data===``?ln(n):n}if(t){if(k?.nodeType!==3){var r=sn();return k?.before(r),Ce(r),r}pn(k)}return k}function z(e,t=!1){if(!O)return cn(e);var n=L(e,t);return A(e),n}function B(e,t=1,n=!1){let r=O?k:e;for(var i;t--;)i=r,r=ln(r);if(!O)return r;if(n){if(r?.nodeType!==3){var a=sn();return r===null?i?.after(a):r.before(a),Ce(a),a}pn(r)}return Ce(r),r}function un(e){e.textContent=``}function dn(){return!1}function fn(e,t,n){return t==null||t===`http://www.w3.org/1999/xhtml`?n?document.createElement(e,{is:n}):document.createElement(e):n?document.createElementNS(t,e,{is:n}):document.createElementNS(t,e)}function pn(e){if(e.nodeValue.length<65536)return;let t=e.nextSibling;for(;t!==null&&t.nodeType===3;)t.remove(),e.nodeValue+=t.nodeValue,t=e.nextSibling}function mn(e){var t=U;if(t===null)return H.f|=ie,e;if(!(t.f&32768)&&!(t.f&4))throw e;hn(e,t)}function hn(e,t){if(!(t!==null&&t.f&16384)){for(;t!==null;){if(t.f&128&&!(t.f&33570816)){if(!(t.f&32768))throw e;try{t.b.error(e);return}catch(t){e=t}}t=t.parent}throw e}}function gn(e){U===null&&(H===null&&Ie(e),Fe()),Hn&&Pe(e)}function _n(e,t){var n=t.last;n===null?t.last=t.first=e:(n.next=e,e.prev=n,t.last=e)}function vn(e,t){var n=U;n!==null&&n.f&8192&&(e|=b);var r={ctx:j,deps:null,nodes:null,f:e|v|512,first:null,fn:t,last:null,next:null,parent:n,b:n&&n.b,prev:null,teardown:null,wv:0,ac:null};P?.register_created_effect(r);var i=r;if(e&4)At===null?Pt.ensure().schedule(r):At.push(r);else if(t!==null){try{ur(r)}catch(e){throw Mn(r),e}i.deps===null&&i.teardown===null&&i.nodes===null&&i.first===i.last&&!(i.f&524288)&&(i=i.first,e&16&&e&65536&&i!==null&&(i.f|=w))}if(i!==null&&(i.parent=n,n!==null&&_n(i,n),H!==null&&H.f&2&&!(e&64))){var a=H;(a.effects??=[]).push(i)}return r}function yn(){return H!==null&&!Wn}function bn(e){let t=vn(8,null);return M(t,_),t.teardown=e,t}function xn(e){gn(`$effect`);var t=U.f;if(!H&&t&32&&j!==null&&!j.i){var n=j;(n.e??=[]).push(e)}else return Sn(e)}function Sn(e){return vn(4|ee,e)}function Cn(e){Pt.ensure();let t=vn(64|T,e);return(e={})=>new Promise(n=>{e.outro?Fn(t,()=>{Mn(t),n(void 0)}):(Mn(t),n(void 0))})}function wn(e){return vn(4,e)}function Tn(e){return vn(re|T,e)}function En(e,t=0){return vn(8|t,e)}function V(e,t=[],n=[],r=[]){dt(r,t,n,t=>{vn(8,()=>{e(...t.map(W))})})}function Dn(e,t=0){return vn(16|t,e)}function On(e){return vn(32|T,e)}function kn(e){var t=e.teardown;if(t!==null){let n=Hn,r=H;Un(!0),Gn(null);try{t.call(null)}catch(t){hn(t,e.parent)}finally{Un(n),Gn(r)}}}function An(e,t=!1){var n=e.first;for(e.first=e.last=null;n!==null;){let e=n.ac;e!==null&<(()=>{e.abort(me)});var r=n.next;n.f&64?n.parent=null:Mn(n,t),n=r}}function jn(e){for(var t=e.first;t!==null;){var n=t.next;t.f&32||Mn(t),t=n}}function Mn(e,t=!0){var n=!1;(t||e.f&262144)&&e.nodes!==null&&e.nodes.end!==null&&(Nn(e.nodes.start,e.nodes.end),n=!0),e.f|=C,An(e,t&&!n),lr(e,0);var r=e.nodes&&e.nodes.t;if(r!==null)for(let e of r)e.stop();kn(e),e.f^=C,e.f|=x;var i=e.parent;i!==null&&i.first!==null&&Pn(e),e.next=e.prev=e.teardown=e.ctx=e.deps=e.fn=e.nodes=e.ac=e.b=null}function Nn(e,t){for(;e!==null;){var n=e===t?null:ln(e);e.remove(),e=n}}function Pn(e){var t=e.parent,n=e.prev,r=e.next;n!==null&&(n.next=r),r!==null&&(r.prev=n),t!==null&&(t.first===e&&(t.first=r),t.last===e&&(t.last=n))}function Fn(e,t,n=!0){var r=[];e.f|=256,In(e,r,!0);var i=()=>{n&&Mn(e),t&&t()},a=r.length;if(a>0){var o=()=>--a||i();for(var s of r)s.out(o)}else i()}function In(e,t,n){if(!(e.f&8192)){e.f^=b;var r=e.nodes&&e.nodes.t;if(r!==null)for(let e of r)(e.is_global||n)&&t.push(e);for(var i=e.first;i!==null;){var a=i.next;if(!(i.f&64)){var o=!!(i.f&65536)||!!(i.f&32)&&!!(e.f&16);In(i,t,o?n:!1)}i=a}}}function Ln(e){e.f&=-257,Rn(e,!0)}function Rn(e,t){if(!(e.f&256)&&e.f&8192){e.f^=b,e.f&1024||(M(e,v),Pt.ensure().schedule(e));for(var n=e.first;n!==null;){var r=n.next,i=!!(n.f&65536)||!!(n.f&32);Rn(n,i?t:!1),n=r}var a=e.nodes&&e.nodes.t;if(a!==null)for(let e of a)(e.is_global||t)&&e.in()}}function zn(e,t){if(e.nodes)for(var n=e.nodes.start,r=e.nodes.end;n!==null;){var i=n===r?null:ln(n);t.append(n),n=i}}var Bn=null,Vn=!1,Hn=!1;function Un(e){Hn=e}var H=null,Wn=!1;function Gn(e){H=e}var U=null;function Kn(e){U=e}var qn=null;function Jn(e){H!==null&&(qn??=new Set).add(e)}var Yn=null,Xn=0,Zn=null;function Qn(e){Zn=e}var $n=1,er=0,tr=er;function nr(e){tr=e}function rr(){return++$n}function ir(e){var t=e.f;if(t&2048)return!0;if(t&2&&(e.f&=~E),t&4096){for(var n=e.deps,r=n.length,i=0;ie.wv)return!0}t&512&&Et===null&&M(e,_)}return!1}function ar(e,t,n=!0){var r=e.reactions;if(r!==null&&!(qn!==null&&qn.has(e)))for(var i=0;i{e.ac.abort(me)}),e.ac=null);try{e.f|=ne;var u=e.fn,d=u();e.f|=S;var f=sr(e);if(Ye()&&Zn!==null&&!Wn&&f!==null&&!(e.f&6146))for(var p=0;p0)for(t.length=Xn+Yn.length,r=0;r{s.ac.abort(me),s.ac=null,M(s,v)}),St(s),lr(s,0)}}function lr(e,t){var n=e.deps;if(n!==null)for(var r=t;rn?.call(this,e))}return e.startsWith(`pointer`)||e.startsWith(`touch`)||e===`wheel`?Qe(()=>{t.addEventListener(e,i,r)}):t.addEventListener(e,i,r),i}function xr(e,t,n,r,i){var a={capture:r,passive:i},o=br(e,t,n,a);(t===document.body||t===window||t===document||t instanceof HTMLMediaElement)&&bn(()=>{t.removeEventListener(e,o,a)})}function G(e,t,n){(t[_r]??={})[e]=n}function Sr(e){for(var t=0;t{wr=!1,Cr=null}));var s=0,c=Cr===e&&e[_r];if(c){var l=a.indexOf(c);if(l!==-1&&(t===document||t===window)){e[_r]=t;return}var u=a.indexOf(t);if(u===-1)return;l<=u&&(s=l)}if(o=a[s]||e.target,o!==t){i(e,`currentTarget`,{configurable:!0,get(){return o||n}});var d=H,f=U;Gn(null),Kn(null);try{for(var p,m=[];o!==null&&o!==t;){try{var h=o[_r]?.[r];h!=null&&(!o.disabled||e.target===o)&&h.call(o,e)}catch(e){p?m.push(e):p=e}if(e.cancelBubble)break;s++,o=s{throw e});throw p}}finally{e[_r]=t,delete e.currentTarget,Gn(d),Kn(f)}}}var Er=globalThis?.window?.trustedTypes&&globalThis.window.trustedTypes.createPolicy(`svelte-trusted-html`,{createHTML:e=>e});function Dr(e){return Er?.createHTML(e)??e}function Or(e){var t=fn(`template`);return t.innerHTML=Dr(e.replaceAll(``,``)),t.content}function kr(e,t){var n=U;n.nodes===null&&(n.nodes={start:e,end:t,a:null,t:null})}function K(e,t){var n=!!(t&1),r=!!(t&2),i,a=!e.startsWith(``);return()=>{if(O)return kr(k,null),k;i===void 0&&(i=Or(a?e:``+e),n||(i=cn(i)));var t=r||nn?document.importNode(i,!0):i.cloneNode(!0);if(n){var o=cn(t),s=t.lastChild;kr(o,s)}else kr(t,t);return t}}function Ar(e,t,n=`svg`){var r=!e.startsWith(``),i=!!(t&1),a=`<${n}>${r?e:``+e}`,o;return()=>{if(O)return kr(k,null),k;if(!o){var e=cn(Or(a));if(i)for(o=document.createDocumentFragment();cn(e);)o.appendChild(cn(e));else o=cn(e)}var t=o.cloneNode(!0);if(i){var n=cn(t),r=t.lastChild;kr(n,r)}else kr(t,t);return t}}function jr(e,t){return Ar(e,t,`svg`)}function Mr(){if(O)return kr(k,null),k;var e=document.createDocumentFragment(),t=document.createComment(``),n=sn();return e.append(t,n),kr(t,n),e}function q(e,t){if(O){var n=U;(!(n.f&32768)||n.nodes.end===null)&&(n.nodes.end=k),we();return}e!==null&&e.before(t)}function Nr(){if(O&&k&&k.nodeType===8&&k.textContent?.startsWith(`$`)){let e=k.textContent.substring(1);return we(),e}return(window.__svelte??={}).uid??=1,`c${window.__svelte.uid++}`}function Pr(e){let t=0,n=Gt(0),r;return()=>{yn()&&(W(n),En(()=>(t===0&&(r=mr(()=>e(()=>Xt(n)))),t+=1,()=>{Qe(()=>{--t,t===0&&(r?.(),r=void 0,Xt(n))})})))}}var Fr=w|T;function Ir(e,t,n,r){new Lr(e,t,n,r)}var Lr=class{parent;is_pending=!1;transform_error;#e;#t=O?k:null;#n;#r;#i;#a=null;#o=null;#s=null;#c=null;#l=0;#u=0;#d=!1;#f=new Set;#p=new Set;#m=null;#h=Pr(()=>(this.#m=Gt(this.#l),()=>{this.#m=null}));constructor(e,t,n,r){this.#e=e,this.#n=t,this.#r=e=>{var t=U;t.b=this,t.f|=128,n(e)},this.parent=U.b,this.transform_error=r??this.parent?.transform_error??(e=>e),this.#i=Dn(()=>{if(O){let e=this.#t;we();let t=e.data===`[!`;if(e.data.startsWith(`[?`)){let t=JSON.parse(e.data.slice(2));this.#_(t)}else t?this.#y():this.#g()}else this.#b()},Fr),O&&(this.#e=k)}#g(){try{this.#a=On(()=>this.#r(this.#e))}catch(e){this.error(e)}}#_(e){let t=this.#n.failed,{reset:n,invoke_onerror:r}=this.#v(e);Qe(r),t&&(this.#s=On(()=>{t(this.#e,()=>e,()=>n)}))}#v(e){var t=!1,n=!1;let r=()=>{if(t){xe();return}t=!0,n&&He(),this.#s!==null&&Fn(this.#s,()=>{this.#s=null}),this.#S(()=>{this.#b()})};return{reset:r,invoke_onerror:()=>{try{n=!0,this.#n.onerror?.(e,r),n=!1}catch(e){hn(e,this.#i&&this.#i.parent)}}}}#y(){let e=this.#n.pending;e&&(this.is_pending=!0,this.#o=On(()=>e(this.#e)),Qe(()=>{var e=this.#c=document.createDocumentFragment(),t=sn(),n=!1;if(e.append(t),this.#a=this.#S(()=>{try{return On(()=>this.#r(t))}catch(e){try{this.error(e),n=!0}catch(e){hn(e,this.#i.parent)}return null}}),this.#a===null){this.#c=null,n&&this.#x(P);return}this.#u===0&&(this.#e.before(e),this.#c=null,Fn(this.#o,()=>{this.#o=null}),this.#x(P))}))}#b(){try{if(this.is_pending=this.has_pending_snippet(),this.#u=0,this.#l=0,this.#a=On(()=>{this.#r(this.#e)}),this.#u>0){var e=this.#c=document.createDocumentFragment();zn(this.#a,e);let t=this.#n.pending;this.#o=On(()=>t(this.#e))}else this.#x(P)}catch(e){this.error(e)}}#x(e){this.is_pending=!1,e.transfer_effects(this.#f,this.#p)}defer_effect(e){rt(e,this.#f,this.#p)}is_rendered(){return!this.is_pending&&(!this.parent||this.parent.is_rendered())}has_pending_snippet(){return!!this.#n.pending}#S(e){var t=U,n=H,r=j;Kn(this.#i),Gn(this.#i),Ge(this.#i.ctx);try{return Pt.ensure(),e()}finally{Kn(t),Gn(n),Ge(r)}}#C(e,t){if(!this.has_pending_snippet()){this.parent&&this.parent.#C(e,t);return}this.#u+=e,this.#u===0&&(this.#x(t),this.#o&&Fn(this.#o,()=>{this.#o=null}),this.#c&&=(this.#e.before(this.#c),null))}update_pending_count(e,t){this.#C(e,t),this.#l+=e,!(!this.#m||this.#d)&&(this.#d=!0,Qe(()=>{this.#d=!1,this.#m&&qt(this.#m,this.#l)}))}get_effect_pending(){return this.#h(),W(this.#m)}error(e){if(!this.#n.onerror&&!this.#n.failed)throw e;P?.is_fork?(this.#a&&P.skip_effect(this.#a),this.#o&&P.skip_effect(this.#o),this.#s&&P.skip_effect(this.#s),P.oncommit(()=>{this.#w(e)})):this.#w(e)}#w(e){this.#a&&=(Mn(this.#a),null),this.#o&&=(Mn(this.#o),null),this.#s&&=(Mn(this.#s),null),O&&(Ce(this.#t),Te(),Ce(Ee()));let t=this.#n.failed,n=e=>{let{reset:n,invoke_onerror:r}=this.#v(e);r(),t&&(this.#s=this.#S(()=>{try{return On(()=>{var r=U;r.b=this,r.f|=128,t(this.#e,()=>e,()=>n)})}catch(e){return hn(e,this.#i.parent),null}}))};Qe(()=>{var t;try{t=this.transform_error(e)}catch(e){hn(e,this.#i&&this.#i.parent);return}typeof t==`object`&&t&&typeof t.then==`function`?t.then(n,e=>hn(e,this.#i&&this.#i.parent)):n(t)})}};function J(e,t){var n=t==null?``:typeof t==`object`?`${t}`:t;n!==(e[fe]??=e.nodeValue)&&(e[fe]=n,e.nodeValue=`${n}`)}function Rr(e,t){return Br(e,t)}var zr=new Map;function Br(e,{target:t,anchor:n,props:i={},events:a,context:o,intro:s=!0,transformError:c}){on();var l=void 0,u=Cn(()=>{var s=n??t.appendChild(sn());Ir(s,{pending:()=>{}},t=>{Ke({});var n=j;if(o&&(n.c=o),a&&(i.$$events=a),O&&kr(t,null),l=e(t,i)||Je(),O&&(U.nodes.end=k,k===null||k.nodeType!==8||k.data!==`]`))throw ye(),ge;qe()},c);var u=new Set,d=e=>{for(var n=0;n{for(var e of u)for(let n of[t,document]){var r=zr.get(n),i=r.get(e);--i==0?(n.removeEventListener(e,Tr),r.delete(e),r.size===0&&zr.delete(n)):r.set(e,i)}yr.delete(d),s!==n&&s.parentNode?.removeChild(s)}});return Vr.set(l,u),l}var Vr=new WeakMap,Hr=class{anchor;#e=new Map;#t=new Map;#n=new Map;#r=new Set;#i=!0;constructor(e,t=!0){this.anchor=e,this.#i=t}#a=e=>{if(this.#e.has(e)){var t=this.#e.get(e),n=this.#t.get(t);if(n)Ln(n),this.#r.delete(t);else{var r=this.#n.get(t);r&&(Ln(r.effect),this.#t.set(t,r.effect),this.#n.delete(t),r.fragment.lastChild.remove(),this.anchor.before(r.fragment),n=r.effect)}for(let[t,n]of this.#e){if(this.#e.delete(t),t===e)break;let r=this.#n.get(n);r&&(Mn(r.effect),this.#n.delete(n))}for(let[e,r]of this.#t){if(e===t||this.#r.has(e))continue;let i=()=>{if(Array.from(this.#e.values()).includes(e)){var t=document.createDocumentFragment();zn(r,t),t.append(sn()),this.#n.set(e,{effect:r,fragment:t})}else Mn(r);this.#r.delete(e),this.#t.delete(e)};this.#i||!n?(this.#r.add(e),Fn(r,i,!1)):i()}}};#o=e=>{this.#e.delete(e);let t=Array.from(this.#e.values());for(let[e,n]of this.#n)t.includes(e)||(Mn(n.effect),this.#n.delete(e))};ensure(e,t){var n=P,r=dn();if(t&&!this.#t.has(e)&&!this.#n.has(e)){if(r){var i=document.createDocumentFragment(),a=sn();i.append(a),this.#n.set(e,{effect:On(()=>t(a)),fragment:i})}else this.#t.set(e,On(()=>t(this.anchor)))}if(this.#e.set(n,e),r){for(let[t,r]of this.#t)t===e?n.unskip_effect(r):n.skip_effect(r);for(let[t,r]of this.#n)t===e?n.unskip_effect(r.effect):n.skip_effect(r.effect);n.oncommit(this.#a),n.ondiscard(this.#o)}else O&&(this.anchor=k),this.#a(n)}};function Y(e,t,n=!1){var r;O&&(r=k,we());var i=new Hr(e),a=n?w:0;function o(e,t){if(O){var n=De(r);if(e!==parseInt(n.substring(1))){var a=Ee();Ce(a),i.anchor=a,Se(!1),i.ensure(e,t),Se(!0);return}}i.ensure(e,t)}Dn(()=>{var e=!1;t((t,n=0)=>{e=!0,o(n,t)}),e||o(-1,null)},a)}var Ur=Symbol(`NaN`);function Wr(e,t,n){O&&we();var r=new Hr(e),i=!Ye();Dn(()=>{var e=t();e!==e&&(e=Ur),i&&typeof e==`object`&&e&&(e={}),r.ensure(e,n)})}function X(e,t){return t}function Gr(e,t,n){for(var i=[],a=t.length,o,s=t.length,c=0;c{if(o){if(o.pending.delete(n),o.done.add(n),o.pending.size===0){var t=e.outrogroups;Kr(e,r(o.done)),t.delete(o),t.size===0&&(e.outrogroups=null)}}else--s},!1)}if(s===0){var l=i.length===0&&n!==null&&e.pending.size===0;if(l){var u=n,d=u.parentNode;un(d),d.append(u),e.items.clear()}Kr(e,t,!l)}else o={pending:new Set(t),done:new Set},(e.outrogroups??=new Set).add(o)}function Kr(e,t,n=!0){var r;if(e.pending.size>0){r=new Set;for(let t of e.pending.values())for(let n of t)r.add(e.items.get(n).e)}for(var i=0;i{var t=i();return e(t)?t:t==null?[]:r(t)}),p,m=new Map,h=!0;function g(e){v.effect.f&16384||(v.pending.delete(e),v.fallback=d,Yr(v,p,c,n,a),d!==null&&(p.length===0?d.f&33554432?(d.f^=te,Zr(d,null,c)):Ln(d):Fn(d,()=>{d=null})))}function _(e){v.pending.delete(e)}var v={effect:Dn(()=>{p=W(f);var e=p.length;let t=!1;O&&De(c)===`[!`!=(e===0)&&(c=Ee(),Ce(c),Se(!1),t=!0);for(var r=new Set,u=P,v=dn(),y=0;ys(c)):(d=On(()=>s(qr??=sn())),d.f|=te)),e>r.size&&Ne(``,``,``),O&&e>0&&Ce(Ee()),!h){if(m.set(u,r),v){for(let[e,t]of l)r.has(e)||u.skip_effect(t.e);u.oncommit(g),u.ondiscard(_)}else g(u)}t&&Se(!0),W(f)}),flags:n,items:l,pending:m,outrogroups:null,fallback:d};h=!1,O&&(c=k)}function Jr(e){for(;e!==null&&!(e.f&32);)e=e.next;return e}function Yr(e,t,n,i,a){var o=!!(i&8),s=t.length,c=e.items,l=Jr(e.effect.first),u,d=null,f,p=[],m=[],h,g,_,v;if(o)for(v=0;v0){var ee=i&4&&s===0?n:null;if(o){for(v=0;v{if(f!==void 0)for(_ of f)_.nodes?.a?.apply()})}function Xr(e,t,n,r,i,a,o,s){var c=o&1?o&16?Gt(n):Kt(n,!1,!1):null,l=o&2?Gt(i):null;return{v:c,i:l,e:On(()=>(a(t,c??n,l??i,s),()=>{e.delete(r)}))}}function Zr(e,t,n){if(e.nodes)for(var r=e.nodes.start,i=e.nodes.end,a=t&&!(t.f&33554432)?t.nodes.start:n;r!==null;){var o=ln(r);if(a.before(r),r===i)return;r=o}}function Qr(e,t,n){t===null?e.effect.first=n:t.next=n,n===null?e.effect.last=t:n.prev=t}function $r(e,t,n){var r;O&&(r=k,we());var i=new Hr(e);Dn(()=>{var e=t()??null;if(O&&De(r)===`[`!=(e!==null)){var a=Ee();Ce(a),i.anchor=a,Se(!1),i.ensure(e,e&&(t=>n(t,e))),Se(!0);return}i.ensure(e,e&&(t=>n(t,e)))},w)}var ei=[...` -\r\f\xA0\v`];function ti(e,t,n){var r=e==null?``:``+e;if(t&&(r=r?r+` `+t:t),n){for(var i of Object.keys(n))if(n[i])r=r?r+` `+i:i;else if(r.length)for(var a=i.length,o=0;(o=r.indexOf(i,o))>=0;){var s=o+a;(o===0||ei.includes(r[o-1]))&&(s===r.length||ei.includes(r[s]))?r=(o===0?``:r.substring(0,o))+r.substring(s+1):o=s}}return r===``?null:r}function ni(e,t=!1){var n=t?` !important;`:`;`,r=``;for(var i of Object.keys(e)){var a=e[i];a!=null&&a!==``&&(r+=` `+i+`: `+a+n)}return r}function ri(e){return e[0]!==`-`||e[1]!==`-`?e.toLowerCase():e}function ii(e,t){if(t){var n=``,r,i;if(Array.isArray(t)?(r=t[0],i=t[1]):r=t,e){e=String(e).replaceAll(/\/\*.*?\*\//g,``).trim();var a=!1,o=0,s=!1,c=[];r&&c.push(...Object.keys(r).map(ri)),i&&c.push(...Object.keys(i).map(ri));var l=0,u=-1;let t=e.length;for(var d=0;d{t.every(mi)||(`__defaultValue`in e&&li(e,!1),`__value`in e&&ui(e,e.__value))});t.observe(e,{childList:!0,subtree:!0,attributes:!0,attributeFilter:[`value`]}),bn(()=>{t.disconnect()})}function fi(e,t,n=t){var r=new WeakSet,i=!0;ut(e,`change`,t=>{var i=t?`[selected]`:`:checked`,a;if(e.multiple)a=[].map.call(e.querySelectorAll(i),pi);else{var o=e.querySelector(i)??e.querySelector(`option:not([disabled])`);a=o&&pi(o)}n(a),e.__value=a,P!==null&&r.add(P)}),wn(()=>{var a=t();if(e===document.activeElement){var o=P;if(r.has(o))return}if(ui(e,a,i),i&&a===void 0){var s=e.querySelector(`:checked`);s!==null&&(a=pi(s),n(a))}e.__value=a,i=!1})}function pi(e){return`__value`in e?e.__value:e.value}function mi(e){if(e.target.closest(`selectedcontent`)!==null)return!0;if(e.type===`childList`){var t=[...e.addedNodes,...e.removedNodes];return t.length>0&&t.every(e=>e.nodeName===`SELECTEDCONTENT`)}return!1}var hi=Symbol(`is custom element`),gi=Symbol(`is html`),_i=he?`link`:`LINK`;function vi(e){if(O){var t=!1,n=()=>{if(!t){if(t=!0,e.hasAttribute(`value`)){var n=e.value;Q(e,`value`,null),e.value=n}if(e.hasAttribute(`checked`)){var r=e.checked;Q(e,`checked`,null),e.checked=r}}};e[pe]=n,Qe(n),ct()}}function Q(e,t,n,r){var i=yi(e);O&&(i[t]=e.getAttribute(t),t===`src`||t===`srcset`||t===`href`&&e.nodeName===_i)||i[t]!==(i[t]=n)&&(t===`loading`&&(e[ce]=n),n==null?e.removeAttribute(t):typeof n!=`string`&&xi(e).has(t)?e[t]=n:e.setAttribute(t,n))}function yi(e){return e[le]??={[hi]:e.nodeName.includes(`-`),[gi]:e.namespaceURI===_e}}var bi=new Map;function xi(e){var t=e.getAttribute(`is`)||e.nodeName,n=bi.get(t);if(n)return n;bi.set(t,n=new Set);for(var r,i=e,a=Element.prototype;a!==i;){for(var s in r=o(i),r)r[s].set&&s!==`innerHTML`&&s!==`textContent`&&s!==`innerText`&&n.add(s);i=l(i)}return n}function Si(e,t,n=t){var r=new WeakSet;ut(e,`input`,async i=>{var a=i?e.defaultValue:e.value;if(a=Ei(e)?Di(a):a,n(a),P!==null&&r.add(P),await dr(),a!==(a=t())){var o=e.selectionStart,s=e.selectionEnd,c=e.value.length;if(e.value=a??``,s!==null){var l=e.value.length;o===s&&s===c&&l>c?(e.selectionStart=l,e.selectionEnd=l):(e.selectionStart=o,e.selectionEnd=Math.min(s,l))}}}),(O&&e.defaultValue!==e.value||mr(t)==null&&e.value)&&(n(Ei(e)?Di(e.value):e.value),P!==null&&r.add(P)),En(()=>{var n=t();if(e===document.activeElement){var i=P;if(r.has(i))return}Ei(e)&&n===Di(e.value)||(e.type!==`date`||n||e.value)&&n!==e.value&&(e.value=n??``)})}var Ci=new Set;function wi(e,t,n,r,i=r){var a=n.getAttribute(`type`)===`checkbox`,o=e;let s=!1;if(t!==null)for(var c of t)o=o[c]??=[];o.push(n),ut(n,`change`,()=>{var e=n.__value;a&&(e=Ti(o,e,n.checked)),i(e)},()=>i(a?[]:null)),En(()=>{var e=r();if(O&&n.defaultChecked!==n.checked){s=!0;return}a?(e||=[],n.checked=e.includes(n.__value)):n.checked=en(n.__value,e)}),bn(()=>{var e=o.indexOf(n);e!==-1&&o.splice(e,1)}),Ci.has(o)||(Ci.add(o),Qe(()=>{o.sort((e,t)=>e.compareDocumentPosition(t)===4?-1:1),Ci.delete(o)})),Qe(()=>{if(s){var e=a?Ti(o,e,n.checked):o.find(e=>e.checked)?.__value;i(e)}})}function Ti(e,t,n){for(var r=new Set,i=0;i{var n=this.#e.get(e);n.delete(t),n.size===0&&(this.#e.delete(e),this.#t.unobserve(e))}}#r(){return this.#t??=new ResizeObserver(t=>{for(var n of t){e.entries.set(n.target,n);for(var r of this.#e.get(n.target)||[])r(n)}})}}({box:`border-box`});function ki(e,t,n){var r=Oi.observe(e,()=>n(e[t]));wn(()=>(mr(()=>n(e[t])),r))}function Ai(e,t,n,r,i){var a=()=>{r(n[e])};n.addEventListener(t,a),i?En(()=>{n[e]=i()}):a(),(n===document.body||n===window||n===document)&&bn(()=>{n.removeEventListener(t,a)})}var ji={get(e,t){let n=e.props.length;for(;n--;){let r=e.props[n];if(d(r)&&(r=r()),typeof r==`object`&&r&&t in r)return r[t]}},set(e,t,n){let r=e.props.length;for(;r--;){let i=e.props[r];d(i)&&(i=i());let o=a(i,t);if(o&&o.set)return o.set(n),!0}return!1},getOwnPropertyDescriptor(e,t){let n=e.props.length;for(;n--;){let r=e.props[n];if(d(r)&&(r=r()),typeof r==`object`&&r&&t in r){let e=a(r,t);return e&&!e.configurable&&(e.configurable=!0),e}}},has(e,t){if(t===ae||t===se)return!1;for(let n of e.props)if(d(n)&&(n=n()),n!=null&&t in n)return!0;return!1},ownKeys(e){let t=[];for(let n of e.props)if(d(n)&&(n=n()),n){for(let e in n)t.includes(e)||t.push(e);for(let e of Object.getOwnPropertySymbols(n))t.includes(e)||t.push(e)}return t}};function Mi(...e){return new Proxy({props:e},ji)}function Ni(e,t,n,r){var i=!Ue||!!(n&2),o=!!(n&8),s=!!(n&16),c=r,l=!0,u=void 0,d=()=>s&&i?(u??=ht(r),W(u)):(l&&(l=!1,c=s?mr(r):r),c);let f;if(o){var p=ae in e||se in e;f=a(e,t)?.set??(p&&t in e?n=>e[t]=n:void 0)}var m,h=!1;o?[m,h]=at(()=>e[t]):m=e[t],m===void 0&&r!==void 0&&(m=d(),f&&(i&&Re(t),f(m)));var g=i?()=>{var n=e[t];return n===void 0?d():(l=!0,n)}:()=>{var n=e[t];return n!==void 0&&(c=void 0),n===void 0?c:n};if(i&&!(n&4))return g;if(f){var _=e.$$legacy;return(function(e,t){return arguments.length>0?((!i||!t||_||h)&&f(t?g():e),e):g()})}var v=!1,y=(n&1?ht:vt)(()=>(v=!1,g()));o&&W(y);var b=U;return(function(e,t){if(arguments.length>0){let n=t?W(y):i&&o?Qt(e):e;return I(y,n),v=!0,c!==void 0&&(c=n),e}return Hn&&v||b.f&16384?y.v:W(y)})}function Pi(e){j===null&&je(`onMount`),Ue&&j.l!==null?Fi(j).m.push(e):xn(()=>{let t=mr(e);if(typeof t==`function`)return t})}function Fi(e){var t=e.l;return t.u??={a:[],b:[],m:[]}}typeof window<`u`&&((window.__svelte??={}).v??=new Set).add(`5`);function Ii(e,t){if(e instanceof RegExp)return{keys:!1,pattern:e};var n,r,i,a,o=[],s=``,c=e.split(`/`);for(c[0]||c.shift();i=c.shift();)n=i[0],n===`*`?(o.push(`wild`),s+=`/(.*)`):n===`:`?(r=i.indexOf(`?`,1),a=i.indexOf(`.`,1),o.push(i.substring(1,~r?r:~a?a:i.length)),s+=~r&&!~a?`(?:/([^/]+?))?`:`/([^/]+?)`,~a&&(s+=(~r?`?`:``)+`\\`+i.substring(a))):s+=`/`+i;return{keys:o,pattern:RegExp(`^`+s+(t?`(?=$|/)`:`/?$`),`i`)}}var Li=new class{#e=F(Ri());get _loc(){return W(this.#e)}set _loc(e){I(this.#e,e)}#t=N(()=>this._loc.location);get _location(){return W(this.#t)}set _location(e){I(this.#t,e)}#n=N(()=>this._loc.querystring);get _querystring(){return W(this.#n)}set _querystring(e){I(this.#n,e)}#r=F(void 0);get _params(){return W(this.#r)}set _params(e){I(this.#r,e)}get loc(){return this._loc}get location(){return this._location}get querystring(){return this._querystring}get params(){return this._params}constructor(){typeof window<`u`?window.addEventListener(`hashchange`,()=>{this._loc=Ri()}):console.warn(`[svelte-spa-router] window 'window' is not defined, skipping initiation`)}};function Ri(){let e=typeof window<`u`?window.location.href:``,t=e.indexOf(`#/`),n=t>-1?e.substr(t+1):`/`,r=n.indexOf(`?`),i=``;return r>-1&&(i=n.substr(r+1),n=n.substr(0,r)),{location:n,querystring:i}}function zi(e){e?window.scrollTo(e.__svelte_spa_router_scrollX||0,e.__svelte_spa_router_scrollY||0):window.scrollTo(0,0)}function Bi(e,t){Ke(t,!0);let n=Ni(t,`routes`,19,()=>({})),r=Ni(t,`prefix`,3,``),i=Ni(t,`restoreScrollState`,3,!1),a=Ni(t,`onConditionsFailed`,3,()=>{}),o=Ni(t,`onRouteLoaded`,3,()=>{}),s=Ni(t,`onRouteLoading`,3,()=>{}),c=Ni(t,`onRouteEvent`,3,()=>{});class l{path;component;conditions;userData;props;_pattern;_keys;constructor(e,t){let n=e=>typeof e==`object`&&!!e&&e._sveltesparouter===!0;if(!t||typeof t!=`function`&&!n(t))throw Error(`Invalid component object`);if(!e||typeof e==`string`&&(e.length<1||e.charAt(0)!=`/`&&e.charAt(0)!=`*`)||typeof e==`object`&&!(e instanceof RegExp))throw Error(`Invalid value for "path" argument - strings must start with / or *`);let r=Ii(e);if(this.path=e,n(t)){let e=t;this.component=e.component,this.conditions=e.conditions||[],this.userData=e.userData,this.props=e.props||{}}else{let e=t;this.component=()=>Promise.resolve(e),this.conditions=[],this.props={}}this._pattern=r.pattern,this._keys=r.keys}match(e){if(r()){if(typeof r()==`string`){if(e.startsWith(r()))e=e.substr(r().length)||`/`;else return null}else if(r()instanceof RegExp){let t=e.match(r());if(t&&t[0])e=e.substr(t[0].length)||`/`;else return null}}let t=this._pattern.exec(e);if(t===null)return null;if(this._keys===!1)return t;let n={},i=0;for(;i{u.push(new l(t,e))}):Object.keys(n()).forEach(e=>{let t=n();u.push(new l(e,t[e]))});let d=F(null),f=F(null),p=F({}),m=null,h=null;xn(()=>{history.scrollRestoration=i()?`manual`:`auto`}),xn(()=>{if(!i())return;let e=e=>{m=e.state&&(e.state.__svelte_spa_router_scrollY||e.state.__svelte_spa_router_scrollX)?e.state:null};return window.addEventListener(`popstate`,e),()=>window.removeEventListener(`popstate`,e)});async function g(e,t){await dr(),e(t)}xn(()=>{let e=Li.loc,t=!1;return mr(async()=>{let n=0;for(;n{t=!0}});function _(e){return e&&typeof e==`object`&&Object.keys(e).length?e:null}var v=Mr(),y=R(v),b=e=>{let t=N(()=>W(d));var n=Mr(),r=R(n),i=e=>{var n=Mr();$r(R(n),()=>W(t),(e,t)=>{t(e,Mi({get params(){return W(f)},get onRouteEvent(){return c()}},()=>W(p)))}),q(e,n)},a=e=>{var n=Mr();$r(R(n),()=>W(t),(e,t)=>{t(e,Mi({get onRouteEvent(){return c()}},()=>W(p)))}),q(e,n)};Y(r,e=>{W(f)?e(i):e(a,-1)}),q(e,n)};Y(y,e=>{W(d)&&e(b)}),q(e,v),qe()}var Vi=[`bars`,`pace`,`zone`,`pie`,`fuel`,`resets`],Hi=`codexometer.web.preferences.v1`,Ui={tab:`quota`,view:`bars`,selected:``,defaultDetail:0,layouts:[]};function Wi(){try{let e=JSON.parse(localStorage.getItem(Hi)||`null`);return!e||typeof e!=`object`?Ui:{tab:[`quota`,`sessions`,`usage`].includes(e.tab)?e.tab:`quota`,view:Vi.includes(e.view)?e.view:`bars`,selected:typeof e.selected==`string`?e.selected.slice(0,256):``,defaultDetail:+(e.defaultDetail===1),layouts:Array.isArray(e.layouts)?e.layouts.filter(e=>{if(!e||typeof e!=`object`)return!1;let t=e;return typeof t.id==`string`&&t.id.length<=256&&[0,1,2].includes(t.level)}).slice(-100):[]}}catch{return Ui}}var Gi=Qt(Wi());function Ki(){let e=JSON.stringify(Gi);try{localStorage.setItem(Hi,e)}catch{}}function qi(){return Gi.tab===`quota`?`/quota/`+Gi.view:`/`+Gi.tab}function Ji(e){return Gi.layouts.find(t=>t.id===e)?.level??Gi.defaultDetail}function Yi(e){Gi.defaultDetail=e,Gi.layouts=[]}function Xi(e,t){Gi.layouts=[...Gi.layouts.filter(t=>t.id!==e),{id:e,level:Math.max(0,Math.min(2,t))}].slice(-100)}var $=Qt({data:null,connected:!1,error:``}),Zi=e=>e==null?`Unavailable`:e.toLocaleString(`en-GB`),Qi=e=>!e||String(e).startsWith(`0001-`)?`Not yet available`:new Date(typeof e==`number`?e*1e3:e).toLocaleString(`en-GB`),$i=`codexometer.web.session`,ea;async function ta(e,t,n){if(!ea||!$.connected||!$.data?.control||$.data.sessionsError)throw Error(`Session controls unavailable. Check the live connection.`);return await ea(e,t,n)}function na(){let e=new AbortController,t,n=``;ea=async(t,r,i)=>{let a=await fetch(`/api/control/`+t,{method:`POST`,headers:{Authorization:`Bearer ${n}`,"Content-Type":`application/json`},body:JSON.stringify(r),signal:AbortSignal.any([e.signal,AbortSignal.timeout(8e3),...i?[i]:[]]),cache:`no-store`});if(!a.ok)throw Error(a.status===502?`Outcome uncertain. Check Codex before taking another action; nothing was retried.`:`Action unavailable, expired or changed. Refresh and check Codex.`);return a.json()};try{n=sessionStorage.getItem($i)||``}catch{}let r=location.hash,i=r.startsWith(`#pair=`)?r.slice(6):``;(i||!r||r===`#`)&&(history.replaceState(null,``,`/#`+qi()),window.dispatchEvent(new HashChangeEvent(`hashchange`)));async function a(){if(i)try{let t=await fetch(`/api/pair`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({secret:i}),signal:e.signal});if(!t.ok)throw Error(`Pairing link expired or already used. Restart --web for a fresh link.`);n=(await t.json()).token;try{sessionStorage.setItem($i,n)}catch{}}catch(t){e.signal.aborted||($.error=t instanceof Error?t.message:`Pairing failed`);return}if(!n){$.error=`Open the private pairing link printed by codexometer --web in your terminal.`;return}await o()}async function o(){try{let t=await fetch(`/api/events`,{headers:{Authorization:`Bearer ${n}`},signal:e.signal,cache:`no-store`});if(t.status===401){try{sessionStorage.removeItem($i)}catch{}$.error=`Browser session expired. Restart --web and open its new pairing link.`;return}if(!t.ok||!t.body)throw Error(`Live connection unavailable`);let r=t.body.getReader(),i=new TextDecoder,a=``;for(;!e.signal.aborted;){let{done:e,value:t}=await r.read();if(e)break;a+=i.decode(t,{stream:!0});let n;for(;(n=a.indexOf(` - -`))>=0;){let e=a.slice(0,n);a=a.slice(n+2),e.startsWith(`data: `)&&($.data=JSON.parse(e.slice(6)),$.connected=!0,$.error=``)}}}catch{}finally{$.connected=!1}e.signal.aborted||($.error=`Connection lost — showing last observation. Reconnecting…`,t=setTimeout(o,2500))}return a(),()=>{ea=void 0,e.abort(),clearTimeout(t),$.connected=!1}}var ra=jr(` `,1),ia=jr(` `,1),aa=K(` `),oa=K(`
      Quota observations
      Observed atPeriod elapsedConsumedTrail segment
      `),sa=K(`


      Observed quota - path, not individual session usage. Gaps are not interpolated. Expand the - observation table for times, positions and gaps.

      OBSERVATION TABLE
      `,1),ca=K(`
      CONSUMPTIONQUOTA PERIOD ELAPSED


      `,1);function la(e,t){let n=Nr();Ke(t,!0);let r=Ni(t,`trail`,19,()=>[]),i=F(!1),a=[0,25,50,75,100],o=F(400),s=F(240),c=N(()=>W(o)-24),l=N(()=>W(s)-48),u=N(()=>Math.max(1,W(c)-48)),d=N(()=>Math.max(1,W(l)-20)),f=N(()=>48+Math.max(0,Math.min(100,t.elapsed))/100*W(u)),p=N(()=>W(l)-Math.max(0,Math.min(100,t.used))/100*W(d)),m=N(()=>t.used-t.elapsed),h=N(()=>r().map((e,t)=>`${t===0||e.break?`M`:`L`}${48+e.elapsed/100*W(u)} ${W(l)-e.used/100*W(d)}`).join(` `));var g=ca(),_=R(g),v=L(_),y=L(v),b=z(y),x=B(y),S=B(x);Z(S,17,()=>a,X,(e,t)=>{var n=ra(),r=R(n),i=B(r),a=B(i),o=z(a),s=B(a),f=z(s);V(()=>{Q(r,`x1`,48+W(t)/100*W(u)),Q(r,`x2`,48+W(t)/100*W(u)),Q(r,`y2`,W(l)),Q(i,`y1`,W(l)-W(t)/100*W(d)),Q(i,`x2`,W(c)),Q(i,`y2`,W(l)-W(t)/100*W(d)),Q(a,`x`,48+W(t)/100*W(u)),Q(a,`y`,W(l)+20),J(o,`${W(t)??``}%`),Q(s,`y`,W(l)+4-W(t)/100*W(d)),J(f,`${W(t)??``}%`)}),q(e,n)});var C=B(S),w=B(C),T=B(w),ee=e=>{var t=ia(),n=R(t),i=B(n),a=z(L(i));A(i),V(e=>{Q(n,`d`,W(h)),Q(i,`cx`,48+r()[0].elapsed/100*W(u)),Q(i,`cy`,W(l)-r()[0].used/100*W(d)),J(a,`First observation: ${e??``}`)},[()=>Qi(r()[0].at)]),q(e,t)};Y(T,e=>{r().length&&e(ee)});var te=B(T,2),E=B(te),ne=B(E),re=z(L(ne));A(ne),A(v),A(_);var ie=B(_,2),ae=L(ie),oe=z(B(ae,3),!0);A(ie);var se=B(ie,2),ce=e=>{var t=sa(),a=R(t),o=L(a);Te(2),A(a);var s=B(a,2),c=B(L(s),2),l=e=>{var t=oa(),n=L(t),i=B(L(n),2);Z(i,21,r,X,(e,t,n)=>{var r=aa(),i=L(r),a=L(i),o=z(a,!0);A(i);var s=B(i),c=z(s),l=B(s),u=z(l),d=z(B(l),!0);A(r),V((e,r)=>{Q(a,`datetime`,W(t).at),J(o,e),J(c,`${r??``}%`),J(u,`${W(t).used??``}%`),J(d,n===0?`First observation`:W(t).break?`Gap before this observation`:`Connected to previous observation`)},[()=>Qi(W(t).at),()=>W(t).elapsed.toFixed(1)]),q(e,r)}),A(i),A(n),A(t),q(e,t)};Y(c,e=>{W(i)&&e(l)}),A(s),V(e=>{Q(a,`id`,n+`-trail-summary`),J(o,`○ START ${e??``} // ${r().length??``} - ${r().length===1?`OBSERVATION`:`OBSERVATIONS`}`)},[()=>Qi(r()[0].at)]),Ai(`open`,`toggle`,s,e=>I(i,e),()=>W(i)),q(e,t)};Y(se,e=>{r().length&&e(ce)}),V((e,i,a,m)=>{Q(v,`viewBox`,`0 0 ${W(o)} ${W(s)}`),Q(v,`aria-describedby`,r().length?n+`-trail-summary`:void 0),Q(v,`aria-label`,e),Q(b,`id`,n),Q(x,`width`,W(u)),Q(x,`height`,W(d)),Q(x,`fill`,`url(#${n})`),Q(C,`d`,`M48 20 V${W(l)} H${W(c)}`),Q(w,`y1`,W(l)),Q(w,`x2`,W(c)),Q(te,`x`,48+W(u)/2),Q(te,`y`,W(s)-5),Q(E,`cx`,W(f)),Q(E,`cy`,W(p)),Q(ne,`cx`,W(f)),Q(ne,`cy`,W(p)),J(re,`${t.used??``}% consumed // ${i??``}% of period elapsed`),J(ae,`${t.used??``}% USED // ${a??``}% TIME ELAPSED`),J(oe,m)},[()=>`Consumption zone: ${t.used}% consumed, ${t.elapsed.toFixed(1)}% of quota period elapsed. ${W(m)>0?`Above`:W(m)<0?`Below`:`On`} the steady-consumption line.`,()=>t.elapsed.toFixed(1),()=>t.elapsed.toFixed(1),()=>Math.abs(W(m))<.05?`ON PACE`:W(m)>0?`ABOVE THE LINE — CONSUMING FASTER THAN TIME`:`BELOW THE LINE — WITHIN PACE`]),ki(_,`clientWidth`,e=>I(o,e)),ki(_,`clientHeight`,e=>I(s,e)),q(e,g),qe()}var ua=K(` `),da=K(`

      Quota refresh failed. Values below are the last successful observation.

      `),fa=K(`

      Expiry details unavailable. No listed expiry does not mean no expiry.

      `),pa=K(`

      `),ma=K(`

      Read-only preview. Use the terminal to redeem a reset.

      The backend may return only some credits. This list does not establish - redemption order.

      `),ha=K(` `,1),ga=jr(``),_a=jr(``),va=K(`
      `),ya=K(`

      Cycle duration or reset date unavailable — position cannot be - plotted.

      `),ba=K(`
      −100 // OVER BUDGET+100 // HEADROOM

      `,1),xa=K(`

      Cycle duration unavailable — pace cannot be calculated.

      `),Sa=K(`
      EMPTYFULL
      `),Ca=K(`

      `,1),wa=K(`
      `,1),Ta=K(`

      `),Ea=K(`

      `),Da=K(`

      No quota windows reported yet.

      `),Oa=K(`
      `,1),ka=K(`

      All reported windows are shown. API-equivalent learning and quota status - scoring remain in the terminal for this first preview.

      `,1),Aa=K(` `,1);function ja(e,t){Ke(t,!0);let n=Ni(t,`params`,19,()=>({})),r=Vi,i=F(Qt(Date.now())),a=N(()=>r.includes(n().view||``)?n().view:`bars`);Pi(()=>{let e=setInterval(()=>I(i,Date.now(),!0),1e3);return()=>clearInterval(e)});function o(e){return!e.duration||e.duration<=0||!e.reset?null:Math.max(0,Math.min(100,100*(1-(e.reset*1e3-W(i))/(e.duration*6e4))))}xn(()=>{Gi.view=W(a)});function s(e){let t=e/100*Math.PI*2;return`M60 60 L60 14 A46 46 0 ${+(e>50)} 1 ${60+46*Math.sin(t)} ${60-46*Math.cos(t)} Z`}var c=Aa(),l=R(c);Z(l,21,()=>r,X,(e,t)=>{var n=ua();let r;var i=z(n,!0);V(e=>{Q(n,`href`,`#/quota/`+W(t)),Q(n,`aria-current`,W(a)===W(t)?`page`:void 0),r=ai(n,1,``,null,r,{active:W(a)===W(t)}),J(i,e)},[()=>W(t)===`pace`?`CONSUMPTION PACE`:W(t)===`zone`?`CONSUMPTION ZONE`:W(t)===`fuel`?`FUEL TANK`:W(t).toUpperCase()]),q(e,n)}),A(l);var u=B(l,2),d=e=>{var t=ka(),n=R(t),r=e=>{q(e,da())};Y(n,e=>{$.data.quotaError&&e(r)});var i=B(n,2),c=z(i),l=B(i,2),u=e=>{var t=ma(),n=L(t),r=z(n),i=B(n,4),a=e=>{q(e,fa())};Y(i,e=>{$.data.credits.length||e(a)}),Z(B(i,2),17,()=>$.data.credits,X,(e,t)=>{var n=pa(),r=L(n),i=z(r),a=z(B(r,2),!0);A(n),V(e=>{J(i,`${(W(t).title||`Quota reset`)??``} // ${W(t).status??``}`),J(a,e)},[()=>W(t).expiryKnown?W(t).expires?`EXPIRES `+Qi(W(t).expires):`Does not expire`:`Expiry information unavailable`]),q(e,n)}),Te(2),A(t),V(()=>J(r,`RESET INVENTORY // ${$.data.creditCount??``} AVAILABLE`)),q(e,t)},d=e=>{var t=Oa(),n=R(t);let r;Z(n,21,()=>$.data.meters,X,(e,t)=>{let n=N(()=>o(W(t))),r=N(()=>W(n)===null?null:W(n)-W(t).used);var i=Ea(),c=L(i),l=z(c,!0),u=B(c,2),d=L(u),f=e=>{var n=ha(),r=R(n),i=z(r),a=z(B(r));V(()=>{J(i,`FREE ${100-W(t).used}%`),J(a,`USED ${W(t).used??``}%`)}),q(e,n)},p=e=>{var n=ha(),r=R(n),i=z(r),a=z(B(r));V(()=>{J(i,`USED ${W(t).used??``}%`),J(a,`FREE ${100-W(t).used}%`)}),q(e,n)};Y(d,e=>{W(a)===`fuel`?e(f):e(p,-1)}),A(u);var m=B(u,2);let h;var g=L(m),_=e=>{var n=va(),r=L(n),i=B(L(r)),a=e=>{q(e,ga())},o=e=>{var n=_a();V(e=>Q(n,`d`,e),[()=>s(W(t).used)]),q(e,n)};Y(i,e=>{W(t).used>=100?e(a):W(t).used>0&&e(o,1)}),A(r),A(n),V(()=>Q(r,`aria-label`,`${W(t).used}% quota used`)),q(e,n)},v=e=>{var r=Mr(),i=R(r),a=e=>{{let r=N(()=>W(t).trail||[]);la(e,{get used(){return W(t).used},get elapsed(){return W(n)},get trail(){return W(r)}})}},o=e=>{q(e,ya())};Y(i,e=>{W(n)===null?e(o,-1):e(a)}),q(e,r)},y=e=>{var t=Mr(),n=R(t),i=e=>{var t=ba(),n=R(t),i=B(L(n),2);let a;A(n);var o=B(n,4),s=L(o),c=z(B(s),!0);A(o),V(e=>{a=si(i,``,a,{left:`${(W(r)+100)/2}%`}),J(s,`${W(r)>=0?`+`:``}${e??``} PP `),J(c,W(r)>=0?`WITHIN PACE`:`USING FASTER THAN TIME`)},[()=>W(r).toFixed(1)]),q(e,t)},a=e=>{q(e,xa())};Y(n,e=>{W(r)===null?e(a,-1):e(i)}),q(e,t)},b=e=>{var r=wa(),i=R(r),o=L(i);let s;A(i);var c=B(i,2),l=e=>{q(e,Sa())};Y(c,e=>{W(a)===`fuel`&&e(l)});var u=B(c,2),d=e=>{var t=Ca(),r=R(t),i=z(r),o=B(r,2),s=L(o);let c;A(o),V(e=>{J(i,`RESET CYCLE // ${e??``}% ELAPSED`),c=si(s,``,c,{width:`${W(a)===`fuel`?100-W(n):W(n)}%`})},[()=>Math.floor(W(n))]),q(e,t)};Y(u,e=>{W(n)!==null&&e(d)}),V(()=>{Q(i,`aria-label`,W(a)===`fuel`?`Fuel remaining`:`Quota used`),Q(i,`aria-valuenow`,W(a)===`fuel`?100-W(t).used:W(t).used),s=si(o,``,s,{width:`${W(a)===`fuel`?100-W(t).used:W(t).used}%`})}),q(e,r)};Y(g,e=>{W(a)===`pie`?e(_):W(a)===`zone`?e(v,1):W(a)===`pace`?e(y,2):e(b,-1)}),A(m);var x=B(m,2),S=z(x),C=B(x,2),w=e=>{var n=Ta(),r=z(n,!0);V(()=>J(r,W(t).details)),q(e,n)};Y(C,e=>{W(t).details&&e(w)}),A(i),V(e=>{J(l,W(t).name),h=ai(m,1,`meter-graphic`,null,h,{"bar-graphic":W(a)===`bars`||W(a)===`fuel`}),J(S,`RESETS // ${e??``}`)},[()=>Qi(W(t).reset)]),q(e,i)}),A(n);var i=B(n,2),c=e=>{q(e,Da())};Y(i,e=>{$.data.meters.length||e(c)}),V(()=>r=ai(n,1,`quota-grid`,null,r,{radial:W(a)===`pie`,zone:W(a)===`zone`})),q(e,t)};Y(l,e=>{W(a)===`resets`?e(u):e(d,-1)}),Te(2),V(e=>J(c,`QUOTA // OBSERVED ${e??``}`),[()=>Qi($.data.quotaAt)]),q(e,t)};Y(u,e=>{$.data&&e(d)}),q(e,c),qe()}var Ma=K(`
      `),Na=K(`

      `,1);function Pa(e,t){Ke(t,!0);let n=Ni(t,`values`,19,()=>[]),r=Ni(t,`label`,3,`Token activity`),i=Ni(t,`capacity`,3,0),a=N(()=>Math.max(0,...n())),o=N(()=>i()>n().length?[...Array(i()-n().length).fill(0),...n()]:n());var s=Na(),c=R(s),l=z(c),u=B(c,2);Z(u,21,()=>W(o),X,(e,t)=>{var n=Ma();let r;V((e,t)=>{Q(n,`title`,e),r=si(n,``,r,{height:t})},[()=>W(t).toLocaleString(`en-GB`)+` tokens`,()=>`${100*W(t)/Math.max(1,W(a))}%`]),q(e,n)}),A(u),V((e,t)=>{J(l,`SCALE // 0 — ${e??``} TOKENS`),Q(u,`aria-label`,t)},[()=>W(a).toLocaleString(`en-GB`),()=>`${r()}. Peak ${W(a).toLocaleString(`en-GB`)} tokens.`]),q(e,s),qe()}var Fa=K(`

      `),Ia=K(`

       
      `,1),La=K(`

      Command unavailable from this observation. Open Codex to inspect the - request.

      `),Ra=K(`

      Session controls temporarily unavailable. Check Codex for current state.

      `),za=K(`

      Checking session controls…

      `),Ba=K(`

      `),Va=K(`
      About browser controls

      Controls require a supported live request from a connected shared - app-server session. Local observations alone cannot provide them.

      `),Ha=K(` `,1),Ua=K(` `),Wa=K(`Grants permission beyond this one command. Check the scope - carefully.`),Ga=K(``),Ka=K(``),qa=K(``),Ja=K(``),Ya=K(` `,1),Xa=K(`
    • `),Za=K(`
      View fixed choices

      Type one of these choices exactly. Your answer stays masked.

        `),Qa=K(` `,1),$a=K(`

        `,1),eo=K(``),to=K(`

        `,1),no=K(`

        SESSION CONTROL // EXPERIMENTAL

        `);function ro(e,t){Ke(t,!0);let n=[],r=Ni(t,`observedCommand`,3,``),i=F(null),a=F(Qt([])),o=F(null),s=F(``),c=F(0),l=F(Qt(Date.now())),u=F(!1),d=F(``),f=F(!1),p=F(!1),m=F(``),h=N(()=>$.data?.sessions.find(e=>e.id===t.session)?.status),g=N(()=>!$.connected||!!$.data?.sessionsError),_=N(()=>!W(g)&&!W(p)&&!!W(i)?.id&&W(i).kind===`approval`&&W(m)!==W(i).id),v=N(()=>W(_)?W(i).command:r()),y=N(()=>!!W(s)&&W(l)W(i)?.questions?.length?W(i).questions:[{text:`Follow-up message`,secret:!1,freeText:!0,options:[]}]),x=N(()=>W(i)?.kind===`approval`?W(o)!==null:W(a).length===W(b).length&&W(a).every((e,t)=>e.trim().length>0&&(W(b)[t].freeText||W(b)[t].options?.includes(e))));xn(()=>{(W(g)||W(l)>=W(c))&&I(s,``)});let S=new AbortController;Pi(()=>{let e,n=setInterval(()=>{I(l,Date.now(),!0)},1e3);async function r(){try{let e=await ta(`offer`,{session:t.session},S.signal);if(S.signal.aborted)return;I(p,!1),W(i)?.id!==e.id&&(I(s,``),I(o,null),I(a,Array(Math.max(1,e.questions?.length||0)).fill(``),!0)),I(i,e,!0),W(f)&&e.id&&e.id!==W(m)&&(I(d,``),I(f,!1))}catch{S.signal.aborted||(I(i,null),I(p,!0),I(s,``))}finally{S.signal.aborted||(e=setTimeout(r,2e3))}}return r(),()=>{S.abort(),clearTimeout(e),clearInterval(n)}});async function C(){if(!W(i)?.id||W(u)||W(g)||!W(x))return;let e=W(i).id;I(u,!0),I(d,``),I(f,!1),I(s,``);try{let n=await ta(`prepare`,{session:t.session,offer:e,...W(i).kind===`approval`?{choice:W(o)}:{answers:[...W(a)]}},S.signal);W(i)?.id===e&&!S.signal.aborted&&!W(g)&&(I(s,n.confirmation,!0),I(c,Date.parse(n.expires),!0))}catch(e){S.signal.aborted||I(d,e instanceof Error?e.message:`Unable to prepare action.`,!0)}finally{I(u,!1)}}async function w(){if(!W(i)?.id||W(u)||!W(y))return;let e=W(i).id,n=W(i).kind,r=W(s);I(s,``),I(u,!0),I(m,e,!0);try{await ta(`commit`,{session:t.session,offer:e,confirmation:r},S.signal),I(f,!0),I(d,n===`approval`?`Decision sent.`:`Text sent.`,!0)}catch{I(d,`Outcome uncertain. Check Codex before taking another action; nothing was retried.`)}finally{I(u,!1),I(a,[],!0),I(o,null)}}var T=no(),ee=B(L(T),2),te=e=>{var t=Fa();let n;var r=z(t,!0);V(()=>{n=ai(t,1,`svelte-1oupzfc`,null,n,{notice:!W(f),sent:W(f)}),J(r,W(d))}),q(e,t)};Y(ee,e=>{W(d)&&e(te)});var E=B(ee,2),ne=e=>{var t=Ia(),n=R(t),r=z(n,!0),i=z(B(n,2),!0);V(()=>{J(r,W(_)?`EXACT COMMAND`:`LAST OBSERVED COMMAND`),J(i,W(v))}),q(e,t)},re=e=>{q(e,La())};Y(E,e=>{W(v)?e(ne):W(h)===`APPROVAL NEEDED`&&!W(f)&&e(re,1)});var ie=B(E,2),ae=e=>{q(e,Ra())},oe=e=>{q(e,za())},se=e=>{var t=Ha(),n=R(t),r=e=>{var t=Ba(),n=z(t,!0);V(e=>J(n,e),[()=>W(h)===`WORKING`?`Codex is working — nothing to respond to.`:[`APPROVAL NEEDED`,`INPUT NEEDED`].includes(W(h)||``)?`Respond in Codex for this request.`:`Nothing needs a response right now.`]),q(e,t)};Y(n,e=>{(W(h)===`WORKING`||!W(f)&&!W(u))&&e(r)});var i=B(n,2),a=e=>{q(e,Va())},o=N(()=>[`APPROVAL NEEDED`,`INPUT NEEDED`].includes(W(h)||``)&&!W(f));Y(i,e=>{W(o)&&e(a)}),q(e,t)},ce=e=>{var t=to(),r=R(t),d=z(r),f=B(r,2),p=L(f),m=z(p,!0),h=B(p,2),_=e=>{var t=Mr();Z(R(t),17,()=>W(i).choices||[],X,(e,t,r)=>{var i=Ga(),a=L(i);vi(a),a.value=a.__value=r;var s=B(a),c=B(s),l=e=>{var n=Ua(),r=z(n,!0);V(()=>J(r,W(t).detail)),q(e,n)};Y(c,e=>{W(t).detail&&e(l)});var u=B(c,2),d=e=>{q(e,Wa())};Y(u,e=>{W(t).persistent&&e(d)}),A(i),V(()=>J(s,` ${W(t).label??``} `)),wi(n,[],a,()=>W(o),e=>I(o,e)),q(e,i)}),q(e,t)},v=e=>{var t=Mr();Z(R(t),17,()=>W(b),X,(e,t,n)=>{var r=Qa(),i=R(r),o=L(i),s=B(o),c=e=>{var t=Ka();vi(t),Si(t,()=>W(a)[n],e=>W(a)[n]=e),q(e,t)},l=e=>{var r=Ja(),i=L(r);i.value=i.__value=``,Z(B(i),17,()=>W(t).options||[],X,(e,t)=>{var n=qa(),r=z(n,!0),i={};V(()=>{J(r,W(t)),i!==(i=W(t))&&(n.value=(n.__value=i)??``)}),q(e,n)}),A(r),di(r),fi(r,()=>W(a)[n],e=>W(a)[n]=e),q(e,r)},u=e=>{var r=Ya(),i=R(r);ot(i);var o=B(i,2),s=e=>{var n=Ba(),r=z(n);V(e=>J(r,`Suggested answers: ${e??``}`),[()=>W(t).options.join(` · `)]),q(e,n)};Y(o,e=>{W(t).options?.length&&e(s)}),Si(i,()=>W(a)[n],e=>W(a)[n]=e),q(e,r)};Y(s,e=>{W(t).secret?e(c):W(t).freeText?e(u,-1):e(l,1)}),A(i);var d=B(i,2),f=e=>{var n=Za(),r=B(L(n),4);Z(r,21,()=>W(t).options||[],X,(e,t)=>{var n=Xa(),r=z(n,!0);V(()=>J(r,W(t))),q(e,n)}),A(r),A(n),q(e,n)};Y(d,e=>{W(t).secret&&!W(t).freeText&&e(f)}),V(()=>J(o,`${W(t).text??``} `)),q(e,r)}),q(e,t)};Y(h,e=>{W(i).kind===`approval`?e(_):e(v,-1)}),A(f);var S=B(f,2),T=e=>{var t=$a(),n=R(t),r=z(n),a=B(n,2),d=z(a),f=B(a,2);V(e=>{J(r,`Check the target and ${W(i).kind===`approval`?`exact command and permission scope`:`message above`}. This will send to Codex; it may start work using - your quota. Confirmation expires in ${e??``}s.`),a.disabled=W(u)||W(g),J(d,`CONFIRM ${(W(i).kind===`approval`?W(i).choices?.[W(o)]?.label:`SEND`)??``}`)},[()=>Math.max(0,Math.ceil((W(c)-W(l))/1e3))]),G(`click`,a,w),G(`click`,f,()=>{I(s,``)}),q(e,t)},ee=e=>{var t=eo(),n=z(t,!0);V(()=>{t.disabled=W(u)||W(g)||!W(x),J(n,W(u)?`SENDING…`:`REVIEW BEFORE SENDING`)}),G(`click`,t,C),q(e,t)};Y(S,e=>{W(y)?e(T):e(ee,-1)}),V(()=>{J(d,`TARGET // ${W(i).thread??``} // ${(W(i).directory||`Directory unavailable`)??``}`),f.disabled=W(u)||W(y)||W(g),J(m,W(i).kind===`approval`?`Choose a decision`:`Reply to this session`)}),q(e,t)};Y(ie,e=>{W(g)||W(p)?e(ae):W(i)?W(i).id?W(m)!==W(i).id&&e(ce,3):e(se,2):e(oe,1)}),A(T),q(e,T),qe()}Sr([`click`]);var io=K(`
        `);function ao(e,t){Ke(t,!0);let n=Ni(t,`active`,3,!1),r=N(()=>[`LAST REPLY`,`LAST ACTIVITY`].includes(t.session.contextKind)&&!!t.session.text.trim()),i=F(!1),a=F(``),o=F(!1);xn(()=>{if(!W(o))return;let e=setTimeout(()=>{I(o,!1)},150);return()=>clearTimeout(e)}),xn(()=>{t.session.text,t.session.status,I(a,``)});async function s(){if(!W(r)||W(i))return;let e=t.session.text;I(i,!0),I(o,!0),I(a,``);try{await navigator.clipboard.writeText(e),t.session.text===e&&I(a,`Copied.`)}catch{t.session.text===e&&I(a,`Clipboard unavailable. Select the visible text and copy it manually.`)}finally{I(i,!1)}}function c(e){!n()||!W(r)||e.defaultPrevented||e.repeat||e.ctrlKey||e.metaKey||e.altKey||e.key.toLowerCase()!==`c`||e.target instanceof Element&&e.target.closest(`input, textarea, select, [contenteditable]`)||(e.preventDefault(),s())}var l=Mr();xr(`keydown`,tn,c);var u=R(l),d=e=>{var t=io(),n=L(t),r=z(n,!0),c=B(n,2);let l;A(t),V(()=>{J(r,W(a)),c.disabled=W(i),l=ai(c,1,`svelte-543j00`,null,l,{flashed:W(o)})}),G(`click`,c,s),q(e,t)};Y(u,e=>{W(r)&&e(d)}),q(e,l),qe()}Sr([`click`]);var oo=K(`

        `),so=K(`

        `),co=K(`
         
        `),lo=K(`

        Command unavailable from this observation. Open Codex to inspect the - request.

        `),uo=K(`

        `,1),fo=K(`

        `),po=K(`
         
        `,1),mo=K(`
        `),ho=K(`

        Session connection or refresh unavailable. Context and telemetry may be - stale.

        `),go=K(` `),_o=K(``),vo=K(`

        Read only — reply or approve in Codex.

        `),yo=K(`
        `),bo=K(`

        This session is no longer in the current observation. Return to sessions.

        `),xo=K(`

        `),So=K(` `),Co=K(`

        `),wo=K(`

        TOKEN ACTIVITY // 30 SECOND SAMPLES

        `),To=K(`

        TOKENS

        FULL DETAIL →
        `),Eo=K(`

        No locally observed sessions yet. Keep Codex running alongside - Codexometer.

        `),Do=K(`

        ↑ ↓ SELECT SESSION // ← LESS DETAIL // → MORE DETAIL // ESC BACK

        `,1),Oo=K(`

        SESSION TOTALS

        `,1);function ko(e,t){Ke(t,!0);let n=(e,t=f,n,r)=>{let i=vt(()=>h(n?.(),!0)),a=vt(()=>h(r?.(),!1));var o=po(),c=R(o),l=e=>{var n=oo();let r;var i=z(n,!0);V(e=>{r=ai(n,1,`attention-note`,null,r,{inferred:t().status===`CHECK SESSION`}),J(i,e)},[()=>m(t())]),q(e,n)},u=N(()=>m(t())&&(!W(a)||W(s)||t().status===`CHECK SESSION`));Y(c,e=>{W(u)&&e(l)});var d=B(c,2),p=e=>{var n=so(),r=z(n,!0);V(()=>J(r,t().contextKind||`LAST ACTIVITY`)),q(e,n)};Y(d,e=>{W(i)&&e(p)});var g=B(d,2),_=z(g,!0),v=B(g,2),y=e=>{var n=uo(),r=B(R(n),2),i=z(r,!0),a=B(r,2),o=e=>{var n=co(),r=z(n,!0);V(()=>J(r,t().command)),q(e,n)},c=e=>{q(e,lo())};Y(a,e=>{t().command?e(o):e(c,-1)}),V(()=>J(i,W(s)||t().status!==`APPROVAL NEEDED`?`LAST OBSERVED COMMAND`:$.data?.control?`COMMAND REQUEST`:`COMMAND TO APPROVE IN CODEX`)),q(e,n)};Y(v,e=>{(!W(a)||!$.data?.control)&&(t().command||t().status===`APPROVAL NEEDED`)&&e(y)});var b=B(v,2),x=e=>{var n=fo(),r=z(n);V(()=>J(r,`CONTEXT SOURCE // ${(t().source||`LOCAL`)??``} // OBSERVATION`)),q(e,n)};Y(b,e=>{W(a)||e(x)}),V(()=>J(_,t().text||`No session context available.`)),q(e,o)},r=Ni(t,`params`,19,()=>({})),i=N(()=>$.data?.sessions||[]);xn(()=>{let e=r().id;e&&mr(()=>{Gi.selected=e,Xi(e,2)})});let a=N(()=>W(i).find(e=>e.id===r().id)),o=N(()=>W(i).some(e=>e.id===Gi.selected)?Gi.selected:W(i)[0]?.id),s=N(()=>!$.connected||!!$.data?.sessionsError),c=N(()=>[[`OBSERVED TOKENS`,Zi(W(i).reduce((e,t)=>e+t.tokens,0))],[`LISTED SESSIONS`,Zi(W(i).length)],...[[`WORKING`,`WORKING`],[`AWAITING APPROVAL`,`APPROVAL NEEDED`],[`AWAITING INPUT`,`INPUT NEEDED`],[`CHECK · INFERRED`,`CHECK SESSION`]].map(([e,t])=>[e,W(s)?`—`:Zi(W(i).filter(e=>e.status===t).length)])]),l=N(()=>W(i).filter(e=>[`INPUT NEEDED`,`APPROVAL NEEDED`,`CHECK SESSION`].includes(e.status)));function u(e){Gi.selected=e,dr().then(()=>document.querySelector(`.session-row.selected`)?.scrollIntoView({block:`nearest`}))}function d(e,t){if(u(e),r().id){t<0&&(Xi(e,2),location.hash=`/sessions`);return}let n=Ji(e)+t;n>2?location.hash=`/sessions/`+encodeURIComponent(e):Xi(e,n)}function p(e){let t=e.target;if(!(e.altKey||e.ctrlKey||e.metaKey||e.shiftKey||t.closest(`input, textarea, select, [contenteditable="true"]`))){if(e.key===`Escape`&&r().id)e.preventDefault(),d(r().id,-1);else if([`ArrowLeft`,`ArrowRight`].includes(e.key)&&(r().id||W(o)))e.preventDefault(),d(r().id||W(o),e.key===`ArrowLeft`?-1:1);else if(!r().id&&[`ArrowUp`,`ArrowDown`].includes(e.key)&&W(i).length){e.preventDefault();let t=W(i).findIndex(e=>e.id===W(o));u(W(i)[Math.max(0,Math.min(W(i).length-1,t+(e.key===`ArrowUp`?-1:1)))].id)}}}function m(e){return W(s)?`Last observation only — refresh unavailable. Check Codex for current state.`:e.status===`CHECK SESSION`?`INFERRED INACTIVITY — a quiet session, not a confirmed input or approval request. Local tools may still be running.`:$.data?.control&&[`APPROVAL NEEDED`,`INPUT NEEDED`].includes(e.status)?`Open full detail for supported live controls; otherwise reply in Codex.`:e.status===`APPROVAL NEEDED`?`OBSERVED APPROVAL SIGNAL — approve or decline in Codex.`:e.status===`INPUT NEEDED`?`OBSERVED INPUT SIGNAL — reply in Codex.`:e.status===`TURN COMPLETE`?`OBSERVED TURN COMPLETION — informational, not an approval request.`:``}var _=Oo();xr(`keydown`,tn,p);var v=R(_),y=z(B(L(v),2));A(v);var b=B(v,2);let x;Z(b,21,()=>W(c),X,(e,t)=>{var n=N(()=>g(W(t),2));let r=()=>W(n)[0],i=()=>W(n)[1];var a=mo(),o=L(a),s=z(o,!0),c=z(B(o,2),!0);A(a),V(()=>{J(s,r()),J(c,i())}),q(e,a)}),A(b);var S=B(b,2),C=z(S),w=B(S,2),T=e=>{q(e,ho())};Y(w,e=>{W(s)&&e(T)});var ee=B(w,2),te=e=>{var t=_o();Z(t,21,()=>W(l),X,(e,t)=>{var n=go();let r;var i=z(n);V(e=>{r=ai(n,1,`button`,null,r,{approval:W(t).status===`APPROVAL NEEDED`}),Q(n,`href`,e),J(i,`${W(t).status??``} // ${(W(t).directory||W(t).id)??``}`)},[()=>`#/sessions/`+encodeURIComponent(W(t).id)]),G(`click`,n,()=>u(W(t).id)),q(e,n)}),A(t),q(e,t)};Y(ee,e=>{W(l).length&&!W(s)&&e(te)});var E=B(ee,2),ne=e=>{var t=Mr(),i=R(t),o=e=>{var t=yo(),i=L(t),o=L(i),c=L(o);let l;var d=B(c);A(o);var f=B(o,2);A(i);var p=B(i,2),m=z(p),h=B(p,2),g=L(h),_=L(g);n(_,()=>W(a),()=>!0,()=>!0),A(g);var v=B(g,2),y=e=>{var t=Mr();Wr(R(t),()=>W(a).id,e=>{ro(e,{get session(){return W(a).id},get observedCommand(){return W(a).command}})}),q(e,t)},b=e=>{q(e,vo())};Y(v,e=>{$.data?.control?e(y):e(b,-1)}),A(h),Wr(B(h,2),()=>W(a).id,e=>{ao(e,{get session(){return W(a)},active:!0})}),A(t),V(e=>{l=ai(c,1,`lamp lit`,null,l,{working:W(a).status===`WORKING`&&!W(s)}),J(d,`${(W(s)?`STALE`:W(a).status)??``} // ${W(a).directory??``}`),J(m,`${e??``} TOKENS // ${W(a).id??``} // CONTEXT SOURCE // ${(W(a).source||`LOCAL`)??``}`)},[()=>Zi(W(a).tokens)]),G(`click`,f,()=>{u(r().id),Xi(r().id,2)}),q(e,t)},c=e=>{q(e,bo())};Y(i,e=>{W(a)?e(o):e(c,-1)}),q(e,t)},re=e=>{var t=Do(),r=R(t),a=B(L(r),2),c=L(a),l=B(c,2);A(a),A(r);var f=B(r,2);Z(f,17,()=>W(i),e=>e.id,(e,t)=>{let r=N(()=>Ji(W(t).id));var i=To();let a;var c=L(i),l=L(c),f=L(l);let p;var m=B(f,1,!0);A(l);var h=B(l,2),g=z(h,!0),_=B(h,2),v=L(_);Te(),A(_);var y=B(_,2),b=z(y),x=B(y,2),S=z(x),C=B(x,2),w=L(C),T=B(w,2),ee=z(T,!0),te=B(T,2);A(C);var E=B(C,2),ne=B(E,2),re=e=>{var n=xo(),r=z(n,!0);V(()=>J(r,W(t).status===`CHECK SESSION`?`INFERRED INACTIVITY`:$.data?.control?`OPEN FULL DETAIL OR REPLY IN CODEX`:W(t).status===`APPROVAL NEEDED`?`APPROVE OR DECLINE IN CODEX`:`REPLY IN CODEX`)),q(e,n)},ie=N(()=>!W(s)&&[`APPROVAL NEEDED`,`INPUT NEEDED`,`CHECK SESSION`].includes(W(t).status));Y(ne,e=>{W(ie)&&e(re)}),A(c);var ae=B(c,2),oe=e=>{var r=Co(),i=L(r),a=L(i),c=z(a,!0),l=B(a,2),u=e=>{var n=So(),r=z(n,!0);V(e=>{Q(n,`href`,e),J(r,$.data?.control?`REVIEW IN FULL DETAIL ↗`:`APPROVAL IN CODEX ↗`)},[()=>`#/sessions/`+encodeURIComponent(W(t).id)]),q(e,n)};Y(l,e=>{!W(s)&&W(t).status===`APPROVAL NEEDED`&&e(u)}),A(i);var d=B(i,2);n(d,()=>W(t),()=>!1);var f=B(d,2);{let e=N(()=>W(o)===W(t).id);ao(f,{get session(){return W(t)},get active(){return W(e)}})}A(r),V(()=>J(c,W(t).contextKind||`LAST ACTIVITY`)),q(e,r)};Y(ae,e=>{W(r)>0&&e(oe)});var se=B(ae,2),ce=e=>{var n=wo(),r=B(L(n),2);{let e=N(()=>(W(t).samples||[]).map(e=>e.tokens));Pa(r,{get values(){return W(e)},capacity:120})}var i=z(B(r,2));A(n),V(()=>J(i,`LAST ${(W(t).samples?.length||0)??``} SAMPLES // AUTO SCALE`)),q(e,n)};Y(se,e=>{W(r)<2&&e(ce)}),A(i),V((e,n,c)=>{a=ai(i,1,`session-row`,null,a,{selected:W(o)===W(t).id,wide:W(r)===2,split:W(r)===1}),Q(i,`aria-label`,`Session `+(W(t).directory||W(t).id)),p=ai(f,1,`lamp lit`,null,p,{working:W(t).status===`WORKING`&&!W(s)}),J(m,W(s)?`STALE`:W(t).status),Q(h,`aria-pressed`,W(o)===W(t).id),J(g,W(t).directory||W(t).id),J(v,`${e??``} `),J(b,`${W(t).agents??``} LINKED AGENTS`),J(S,`ACTIVE // ${n??``}`),w.disabled=W(r)===0,Q(T,`aria-expanded`,W(r)>0),J(ee,W(r)?`HIDE DETAIL`:`SHOW DETAIL`),Q(E,`href`,c)},[()=>Zi(W(t).tokens),()=>Qi(W(t).activity),()=>`#/sessions/`+encodeURIComponent(W(t).id)]),G(`click`,h,()=>u(W(t).id)),G(`click`,w,()=>d(W(t).id,-1)),G(`click`,T,()=>{u(W(t).id),Xi(W(t).id,+!W(r))}),G(`click`,te,()=>d(W(t).id,1)),G(`click`,E,()=>u(W(t).id)),q(e,i)});var p=B(f,2),m=e=>{q(e,Eo())};Y(p,e=>{W(i).length||e(m)}),G(`click`,c,()=>Yi(1)),G(`click`,l,()=>Yi(0)),q(e,t)};Y(E,e=>{r().id?e(ne):e(re,-1)}),V(e=>{J(y,`OBSERVED ${e??``}`),x=ai(b,1,`session-totals`,null,x,{stale:W(s)}),J(C,`${W(s)?`LAST KNOWN TOTALS — live state counts unavailable. `:``}Tokens - observed since this server started for currently listed sessions; linked - agents are already included. Totals can decrease when a session leaves the - list. History samples every 30 seconds. Not account-wide totals.`)},[()=>Qi($.data?.sessionsAt)]),q(e,_),qe()}Sr([`click`]);var Ao=864e5;function jo(e,t){let n=new Date(e);n.setUTCDate(1),n.setUTCMonth(n.getUTCMonth()+t);let r=new Date(Date.UTC(n.getUTCFullYear(),n.getUTCMonth()+1,0)).getUTCDate();return n.setUTCDate(Math.min(e.getUTCDate(),r)),n}function Mo(e,t,n){let r=new Date(Date.UTC(e.getUTCFullYear(),e.getUTCMonth(),e.getUTCDate()));for(let e=0;;e++){let i=jo(r,-t),a=new Date(i.getTime()+Ao);if(e===n)return{start:a,end:r};r=i}}var No=K(`

        History refresh failed. Any displayed history is the last successful - observation.

        `),Po=K(``),Fo=K(`
        `),Io=K(`

        LESS ░ ▒ ▓ █ MORE // HOVER FOR DATE AND TOKENS

        `,1),Lo=K(`
        `,1),Ro=K(` `),zo=K(`

        LIFETIME TOKENS

        PEAK DAY

        CURRENT STREAK

        DAYS

        Accessible data table
        Date (UTC)Tokens

        `,1),Bo=K(`

        History unavailable or awaiting a matching account observation. Missing - history is not treated as zero usage.

        `),Vo=K(`

        USAGE // ACCOUNT HISTORY

        Account-wide history reported by Codex, not the local Sessions counter. Dates - use UTC. Historical resets are not provided by this data.

        `,1);function Ho(e,t){Ke(t,!0);let n=F(`daily`),r=F(12),i=F(0),a=N(()=>{let e=$.data?.usage?.dailyUsageBuckets;if(!e)return[];let{start:t,end:n}=Mo(new Date,W(r),W(i)),a=new Map;for(let t of e)!Number.isFinite(t.tokens)||t.tokens<0||a.set(t.startDate,(a.get(t.startDate)||0)+t.tokens);let o=[];for(let e=t;e<=n;e=new Date(e.getTime()+864e5)){let t=e.toISOString().slice(0,10);o.push({date:t,tokens:a.get(t)||0})}return o}),o=N(()=>Math.max(1,...W(a).map(e=>e.tokens))),s=N(()=>W(a).length?new Date(W(a)[0].date+`T00:00:00Z`).getUTCDay():0),c=N(()=>{if(W(n)===`monthly`){let e=new Map;for(let t of W(a))e.set(t.date.slice(0,7),(e.get(t.date.slice(0,7))||0)+t.tokens);return[...e].map(([e,t])=>({date:e,tokens:t}))}let e=0;return W(a).map(t=>({date:t.date,tokens:e+=t.tokens}))});var l=Vo(),u=B(R(l),4),d=L(u),f=B(L(d)),p=L(f);p.value=p.__value=`daily`;var m=B(p);m.value=m.__value=`monthly`;var h=B(m);h.value=h.__value=`cumulative`,A(f),di(f),A(d);var g=B(d),_=B(L(g)),v=L(_);v.value=v.__value=6;var y=B(v);y.value=y.__value=12,A(_),di(_),A(g);var b=B(g),x=B(b);A(u);var S=B(u,2),C=e=>{q(e,No())};Y(S,e=>{$.data?.usageError&&e(C)});var w=B(S,2),T=e=>{var t=zo(),r=R(t),i=L(r),l=z(B(L(i),2),!0);A(i);var u=B(i,2),d=z(B(L(u),2),!0);A(u);var f=B(u,2),p=B(L(f),2),m=L(p);Te(),A(p),A(f),A(r);var h=B(r,2),g=L(h),_=z(g),v=B(g,2),y=e=>{var t=Io(),n=R(t),r=L(n),i=L(r);Z(i,17,()=>Array(W(s)),X,(e,t)=>{q(e,Po())}),Z(B(i,2),17,()=>W(a),X,(e,t)=>{var n=Fo();let r,i;V(e=>{r=ai(n,1,`heat-cell`,null,r,{zero:W(t).tokens===0}),Q(n,`title`,e),i=si(n,``,i,{opacity:W(t).tokens?.25+.75*W(t).tokens/W(o):1})},[()=>`${W(t).date}: ${Zi(W(t).tokens)} tokens`]),q(e,n)}),A(r),A(n),Te(2),q(e,t)},b=e=>{var t=Lo(),r=R(t);{let e=N(()=>W(c).map(e=>e.tokens)),t=N(()=>W(n)+` usage`);Pa(r,{get values(){return W(e)},get label(){return W(t)}})}var i=B(r,2),a=L(i),o=z(a,!0),s=z(B(a),!0);A(i),V(e=>{J(o,W(c)[0]?.date),J(s,e)},[()=>W(c).at(-1)?.date]),q(e,t)};Y(v,e=>{W(n)===`daily`?e(y):e(b,-1)});var x=B(v,2),S=B(L(x),2),C=L(S),w=B(L(C));Z(w,21,()=>W(n)===`daily`?W(a):W(c),X,(e,t)=>{var n=Ro(),r=L(n),i=z(r,!0),a=z(B(r),!0);A(n),V(e=>{J(i,W(t).date),J(a,e)},[()=>Zi(W(t).tokens)]),q(e,n)}),A(w),A(C),A(S),A(x),A(h);var T=z(B(h,2));V((e,t,n,r,i)=>{J(l,e),J(d,t),J(m,`${n??``} `),J(_,`${W(a)[0]?.date??``} — ${r??``}`),J(T,`OBSERVED ${i??``} // CUMULATIVE IS FOR THE SELECTED PERIOD`)},[()=>Zi($.data.usage.summary.lifetimeTokens),()=>Zi($.data.usage.summary.peakDailyTokens),()=>Zi($.data.usage.summary.currentStreakDays),()=>W(a).at(-1)?.date,()=>Qi($.data.usageAt)]),q(e,t)},ee=e=>{q(e,Bo())};Y(w,e=>{$.data?.usage&&$.data.usage.dailyUsageBuckets!==null?e(T):e(ee,-1)}),V(()=>x.disabled=W(i)===0),fi(f,()=>W(n),e=>I(n,e)),G(`change`,_,()=>I(i,0)),fi(_,()=>W(r),e=>I(r,e)),G(`click`,b,()=>Yt(i)),G(`click`,x,()=>Yt(i,-1)),q(e,l),qe()}Sr([`change`,`click`]),We();var Uo=K(`

        Page not found

        Return to Quota

        `,1);function Wo(e){var t=Uo();Te(2),q(e,t)}var Go=K(` `),Ko=K(`

        `),qo=K(`

        Connecting to your local Codexometer…

        `),Jo=K(``),Yo=K(`
        CODEXOMETER

        Your quota. Your sessions. Your command centre.

        `);function Xo(e,t){Ke(t,!0);let n={"/":ja,"/quota/:view?":ja,"/sessions/:id?":ko,"/usage":Ho,"*":Wo},r={quota:/^(?:\/|\/quota(?:\/[^/]+)?\/?)$/,sessions:/^\/sessions(?:\/[^/]+)?\/?$/,usage:/^\/usage\/?$/},i=F(`hacker`),a=[`hacker`,`rust`,`blue-steel`,`ultraviolet`,`nightshade`];xn(()=>{Ki()}),xn(()=>{if($.data)for(let[e,t]of Object.entries(r))t.test(Li.location)&&(Gi.tab=e)}),Pi(()=>{try{let e=localStorage.getItem(`codexometer.web.theme`);e&&a.includes(e)&&I(i,e,!0)}catch{}return na()});function o(){try{localStorage.setItem(`codexometer.web.theme`,W(i))}catch{}}var s=Yo(),c=L(s),l=B(L(c),2),u=L(l);let d;var f=B(u,1,!0),p=z(B(f));A(l),A(c);var m=B(c,2);Z(m,21,()=>Object.entries(r),X,(e,t)=>{var n=N(()=>g(W(t),2));let r=()=>W(n)[0],i=()=>W(n)[1],a=N(()=>i().test(Li.location));var o=Go();let s;var c=z(o,!0);V(e=>{Q(o,`href`,r()===`quota`?`#/quota/`+Gi.view:`#/`+r()),Q(o,`aria-current`,W(a)?`page`:void 0),s=ai(o,1,``,null,s,{active:W(a)}),J(c,e)},[()=>r().toUpperCase()]),q(e,o)}),A(m);var h=B(m,2),_=L(h),v=e=>{var t=Ko(),n=z(t,!0);V(()=>J(n,$.error)),q(e,t)};Y(_,e=>{$.error&&e(v)});var y=B(_,2),b=e=>{Bi(e,{get routes(){return n}})},x=e=>{q(e,qo())};Y(y,e=>{$.data?e(b):$.error||e(x,1)}),A(h);var S=B(h,2),C=L(S),w=z(C),T=B(C,2),ee=z(T,!0),te=B(T,2),E=B(L(te));Z(E,21,()=>a,X,(e,t)=>{var n=Jo(),r=z(n,!0),i={};V(e=>{J(r,e),i!==(i=W(t))&&(n.value=(n.__value=i)??``)},[()=>W(t).replace(`-`,` `).toUpperCase()]),q(e,n)}),A(E),di(E),A(te),A(S),A(s),V(()=>{Q(s,`data-theme`,W(i)),d=ai(u,1,`lamp`,null,d,{lit:$.connected}),J(f,$.connected?`CONNECTED`:`OFFLINE`),J(p,`EXPERIMENTAL // ${$.data?.control?`SESSION CONTROL`:`READ ONLY`}`),J(w,`v${($.data?.version||`…`)??``} // LOCAL ONLY`),J(ee,$.data?.control?`Session control enabled. Stop the server to revoke access.`:`No actions can be sent from this preview.`)}),G(`change`,E,o),fi(E,()=>W(i),e=>I(i,e)),q(e,s),qe()}Sr([`change`]),Rr(Xo,{target:document.getElementById(`app`)}); \ No newline at end of file diff --git a/internal/web/dist/index.html b/internal/web/dist/index.html index 074d495..7663e9a 100644 --- a/internal/web/dist/index.html +++ b/internal/web/dist/index.html @@ -5,7 +5,7 @@ Codexometer // Experimental web - + diff --git a/internal/web/profiles.go b/internal/web/profiles.go new file mode 100644 index 0000000..fe68b0d --- /dev/null +++ b/internal/web/profiles.go @@ -0,0 +1,174 @@ +package web + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "github.com/merefield/codexometer/internal/codex" + "time" +) + +type profileReview struct { + Session string `json:"session"` + Threshold int `json:"threshold"` + Current string `json:"current"` + Proposed string `json:"proposed"` + Pending bool `json:"pending"` + Notice string `json:"notice,omitempty"` +} + +// Presentation adapter; the shared controller owns the policy lifecycle. +type profileControl struct { + engine *codex.QuotaProfiles + client codex.SessionSettingsClient + steps []codex.QuotaStep + refresh time.Duration +} + +func newProfileControl(source Source) *profileControl { + policy, ok := source.(codex.QuotaStepPolicyProvider) + client, supported := source.(codex.SessionSettingsClient) + if !ok || !supported || len(policy.QuotaStepPolicy()) == 0 { + return nil + } + return &profileControl{engine: codex.NewQuotaProfiles(client), client: client, steps: policy.QuotaStepPolicy(), refresh: time.Minute} +} + +func (c *control) profileSnapshot() codex.Snapshot { + c.store.mu.Lock() + defer c.store.mu.Unlock() + if c.store.state.QuotaError { + return codex.Snapshot{} + } + return c.store.quotaSnapshot +} + +func profileText(model, effort string, tier *string) string { + speed := "standard" + if tier != nil && *tier != "default" { + speed = *tier + } + return codex.SanitizeSessionContext(model + " / " + effort + " / " + speed) +} + +func profileNotice(err error) string { + if err == nil { + return "" + } + if errors.Is(err, codex.ErrQuotaProfileUnverified) { + return "Profile update accepted; awaiting verification. Nothing was retried." + } + return "Profile update failed: " + codex.SanitizeSessionContext(err.Error()) +} + +func (c *control) profileInventory(ctx context.Context) ([]offeredAction, error) { + if c.profiles == nil { + return nil, errUnavailable + } + p := c.profiles + inventory, err := p.engine.Inventory(ctx, c.profileSnapshot(), p.steps, p.refresh) + if err != nil { + return nil, err + } + step, window, ok := codex.SelectQuotaProfile(c.profileSnapshot(), p.steps) + if inventory.Step.Model == "" && !ok { + return nil, nil + } + if !ok || step != inventory.Step || window != inventory.Window { + return nil, errUnavailable + } + var offers []offeredAction + for _, s := range inventory.Sessions { + handled, seen := inventory.Handled[s.ID] + pending := !seen || handled < step.Threshold + notice := profileNotice(inventory.Outcomes[s.ID]) + if !pending && notice == "" { + continue + } + proposed := profileText(step.Model, step.Effort, &inventory.Resolved.ServiceTier) + if step.ServiceTier == "" { + proposed = codex.SanitizeSessionContext(step.Model + " / " + step.Effort + " / speed unchanged") + } + review := &profileReview{Session: s.ID, Threshold: step.Threshold, Current: profileText(s.Model, s.Effort, s.Tier), Proposed: proposed, Pending: pending && step.Mode != "auto", Notice: notice} + o := offeredAction{actionOffer: actionOffer{Session: s.ID, Thread: s.ID, Kind: "profile", Profile: review, Choices: []actionChoice{{Label: "APPLY PROFILE"}, {Label: "SKIP"}}}, profileTarget: s, profileStep: step, profileWindow: window} + if pending { + data, _ := json.Marshal([]any{s, step, inventory.Resolved, window}) + h := hmac.New(sha256.New, []byte(c.key)) + h.Write(data) + o.ID = hex.EncodeToString(h.Sum(nil)) + } + offers = append(offers, o) + } + return offers, nil +} + +func (c *control) profileOffer(ctx context.Context, id string) (offeredAction, error) { + offers, err := c.profileInventory(ctx) + if err != nil { + return offeredAction{}, err + } + for _, o := range offers { + if o.Session == id && o.Profile.Pending { + return o, nil + } + } + return offeredAction{actionOffer: actionOffer{Session: id}}, nil +} + +func (c *control) commitProfile(ctx context.Context, o offeredAction, choice int) error { + p := c.profiles + _, err := p.engine.Apply(ctx, c.profileSnapshot(), p.steps, p.refresh, o.profileTarget, o.profileStep, o.profileWindow, choice == 1) + return err +} + +func (c *control) refreshProfiles(ctx context.Context) { + c.mu.Lock() + defer c.mu.Unlock() + offers, err := c.profileInventory(ctx) + if err == nil { + for i, o := range offers { + if o.profileStep.Mode == "auto" && o.ID != "" { + offers[i].Profile.Notice = profileNotice(c.commitProfile(ctx, o, 0)) + break + } + } + } + reviews := []profileReview{} + for _, o := range offers { + if o.Profile.Pending || o.Profile.Notice != "" { + reviews = append(reviews, *o.Profile) + } + } + c.store.mu.Lock() + c.store.state.Profiles = reviews + c.store.state.ProfileError = err != nil + c.store.publish() + c.store.mu.Unlock() +} + +func (c *control) collectProfiles(ctx context.Context, refresh time.Duration) func() { + if c.profiles == nil { + return func() {} + } + c.profiles.refresh = max(10*time.Second, refresh) + done := make(chan struct{}) + go func() { + defer close(done) + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + request, cancel := context.WithTimeout(ctx, 8*time.Second) + c.refreshProfiles(request) + cancel() + } + }() + return func() { <-done; c.profiles.client.CloseQuotaProfiles() } +} diff --git a/internal/web/profiles_test.go b/internal/web/profiles_test.go new file mode 100644 index 0000000..b337da2 --- /dev/null +++ b/internal/web/profiles_test.go @@ -0,0 +1,169 @@ +package web + +import ( + "context" + "encoding/json" + "errors" + "github.com/merefield/codexometer/internal/codex" + "net/http/httptest" + "testing" + "time" +) + +type profileSource struct { + actionSource + steps []codex.QuotaStep + sessions []codex.QuotaSession + writes int + failure error +} + +func (f *profileSource) QuotaStepPolicy() []codex.QuotaStep { return f.steps } +func (f *profileSource) ResolveQuotaStep(_ context.Context, s codex.QuotaStep) (codex.QuotaStep, error) { + return s, nil +} +func (f *profileSource) QuotaSessions(context.Context) ([]codex.QuotaSession, error) { + return append([]codex.QuotaSession{}, f.sessions...), nil +} +func (f *profileSource) CloseQuotaProfiles() {} +func (f *profileSource) ApplyQuotaProfile(_ context.Context, targets []codex.QuotaSession, step codex.QuotaStep) (int, error) { + f.writes++ + if f.failure != nil { + return 0, f.failure + } + for i, s := range f.sessions { + if s.ID == targets[0].ID { + f.sessions[i].Model = step.Model + f.sessions[i].Effort = step.Effort + } + } + return 1, nil +} + +func profileServer(t *testing.T, mode string) (*server, *profileSource, string) { + s, actions, token := controlServer(t) + f := &profileSource{steps: []codex.QuotaStep{{Threshold: 80, Model: "small", Effort: "medium", Mode: mode}}, sessions: []codex.QuotaSession{{ID: "parent", Model: "large", Effort: "high"}}} + f.pending = actions.pending + s.control = newControl(f, s.store) + q := codex.DemoSnapshot() + q.AccountFingerprint = "private-account" + q.FetchedAt = time.Now() + q.RateLimits.Secondary.UsedPercent = 85 + reset := time.Now().Add(time.Hour).Unix() + q.RateLimits.Secondary.ResetsAt = &reset + s.store.quota(q, nil, time.Now()) + return s, f, token +} + +func profileOfferForTest(t *testing.T, s *server, token string) actionOffer { + t.Helper() + w := actionCall(s, token, "offer", actionRequest{Session: "parent", Review: "profile"}) + var o actionOffer + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &o) != nil || o.ID == "" { + t.Fatalf("offer: %d %s", w.Code, w.Body) + } + return o +} + +func TestWebProfileConfirmedOnceAlongsideNativeApproval(t *testing.T) { + s, f, token := profileServer(t, "ask") + s.control.refreshProfiles(context.Background()) + if len(s.store.state.Profiles) != 1 || !s.store.state.Profiles[0].Pending || f.writes != 0 { + t.Fatal("ask wrote or lacked pill") + } + if getOffer(t, s, token).Kind != "approval" { + t.Fatal("profile displaced native approval") + } + o := profileOfferForTest(t, s, token) + choice := 0 + r := prepareAction(t, s, token, actionRequest{Session: "parent", Review: "profile", Offer: o.ID, Choice: &choice}) + r.Review = "profile" + if f.writes != 0 { + t.Fatal("prepare wrote") + } + if w := actionCall(s, token, "commit", r); w.Code != 200 { + t.Fatalf("commit: %s", w.Body) + } + if w := actionCall(s, token, "commit", r); w.Code != 409 || f.writes != 1 { + t.Fatal("replayed profile") + } + s.control.refreshProfiles(context.Background()) + if len(s.store.state.Profiles) != 0 { + t.Fatal("resolved pill retained") + } +} + +func TestWebProfileRejectsDriftAndStaleQuota(t *testing.T) { + for _, change := range []string{"settings", "threshold", "window", "stale", "account", "origin", "auth"} { + t.Run(change, func(t *testing.T) { + s, f, token := profileServer(t, "ask") + o := profileOfferForTest(t, s, token) + choice := 0 + r := prepareAction(t, s, token, actionRequest{Session: "parent", Review: "profile", Offer: o.ID, Choice: &choice}) + r.Review = "profile" + switch change { + case "settings": + f.sessions[0].Effort = "low" + case "threshold": + s.store.quotaSnapshot.RateLimitsByLimitID["codex"].Secondary.UsedPercent = 10 + case "window": + value := time.Now().Add(2 * time.Hour).Unix() + s.store.quotaSnapshot.RateLimitsByLimitID["codex"].Secondary.ResetsAt = &value + case "stale": + s.store.quotaSnapshot.FetchedAt = time.Now().Add(-time.Hour) + case "account": + s.store.quotaSnapshot.AccountFingerprint = "other" + case "auth": + token = "invalid" + } + if change == "origin" { + data, _ := json.Marshal(r) + req := request(s, "POST", "/api/control/commit", string(data), token) + req.Header.Set("Origin", "https://evil.example") + w := httptest.NewRecorder() + s.handler().ServeHTTP(w, req) + if w.Code != 403 { + t.Fatal("foreign origin accepted") + } + } else if w := actionCall(s, token, "commit", r); w.Code == 200 { + t.Fatal("changed review accepted") + } + if f.writes != 0 { + t.Fatal("unsafe profile write") + } + }) + } +} + +func TestWebProfileAutoSkipAndNoRetry(t *testing.T) { + s, f, token := profileServer(t, "auto") + f.failure = errors.New("test rejection") + s.control.refreshProfiles(context.Background()) + s.control.refreshProfiles(context.Background()) + if f.writes != 1 || len(s.store.state.Profiles) != 1 || s.store.state.Profiles[0].Pending || s.store.state.Profiles[0].Notice == "" { + t.Fatal("auto failure retried or hidden") + } + f.sessions = append(f.sessions, codex.QuotaSession{ID: "new", Model: "large", Effort: "high"}) + f.failure = nil + s.control.refreshProfiles(context.Background()) + if f.writes != 2 { + t.Fatal("new session ignored after failure") + } + // Read-only server has no control handlers, even with a profile payload. + s.control = nil + if w := actionCall(s, token, "offer", actionRequest{Session: "parent", Review: "profile"}); w.Code != 404 { + t.Fatal("read-only control exposed") + } + s, f, token = profileServer(t, "ask") + o := profileOfferForTest(t, s, token) + choice := 1 + r := prepareAction(t, s, token, actionRequest{Session: "parent", Review: "profile", Offer: o.ID, Choice: &choice}) + r.Review = "profile" + if w := actionCall(s, token, "commit", r); w.Code != 200 { + t.Fatal(w.Body) + } + s.control.refreshProfiles(context.Background()) + if f.writes != 0 || len(s.store.state.Profiles) != 0 { + t.Fatal("skip changed settings or retained review") + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 4e982bd..6d28e0e 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -52,10 +52,14 @@ func Run(ctx context.Context, source Source, refresh time.Duration, port int, ou s.control = newControl(source, s.store) s.store.state.Control = true s.store.publish() - mode = "SESSION CONTROL ENABLED — this paired browser can send prompts and decisions" + mode = "SESSION CONTROL ENABLED — prompts, decisions and configured quota profiles enabled" } ctx, cancel := context.WithCancel(ctx) defer cancel() + if s.control != nil { + waitProfiles := s.control.collectProfiles(ctx, refresh) + defer func() { cancel(); waitProfiles() }() + } wait := s.store.collect(ctx, source, refresh) defer func() { cancel(); wait() }() httpServer := newHTTPServer(ctx, s.handler()) diff --git a/internal/web/state.go b/internal/web/state.go index a45b5d2..d1c2a5f 100644 --- a/internal/web/state.go +++ b/internal/web/state.go @@ -98,6 +98,8 @@ type credit struct { } type state struct { + Profiles []profileReview `json:"profiles,omitempty"` + ProfileError bool `json:"profileError,omitempty"` Control bool `json:"control"` Version string `json:"version"` Meters []meter `json:"meters"` @@ -114,17 +116,18 @@ type state struct { } type store struct { - contexts map[string]codex.SessionContext // Private: never published in state/SSE. - directories map[string]string - mu sync.Mutex - state state - account string - history codex.AccountUsage - previous map[string]int64 - samples map[string][]sample - nextSample time.Time - data []byte - changed chan struct{} + quotaSnapshot codex.Snapshot + contexts map[string]codex.SessionContext // Private: never published in state/SSE. + directories map[string]string + mu sync.Mutex + state state + account string + history codex.AccountUsage + previous map[string]int64 + samples map[string][]sample + nextSample time.Time + data []byte + changed chan struct{} } func newStore() *store { @@ -152,6 +155,7 @@ func (s *store) quota(q codex.Snapshot, err error, now time.Time) { gap := s.state.QuotaError s.state.QuotaError = err != nil if err == nil { + s.quotaSnapshot = codex.CaptureQuotaProfile(q) previous := s.state.Meters if s.account != q.AccountFingerprint { previous = nil diff --git a/intro-post.md b/intro-post.md index 0b4d7ad..cf24388 100644 --- a/intro-post.md +++ b/intro-post.md @@ -48,7 +48,7 @@ On tall terminals, full detail retains the summary too. As space runs short, the summary disappears before the attention strip so text and action controls keep priority. These shortcuts navigate only; they never approve or send for you. -Optional quota step-down profiles also join the terminal session command centre: +Optional quota step-down profiles also join the session command centre in the terminal and writable web interface: configure `--quota-step-down PERCENT:MODEL:EFFORT[:SPEED[:ask|auto]]`, then follow a **QUOTA THRESHOLD** pill to review the proposed settings for that session. Apply and confirm individually, or skip. A session can have separate pills for @@ -58,6 +58,9 @@ are handled. The default `ask` mode requires approval; opt into `auto` per threshold to apply the profile once per session for subsequent turns without prompting. Already-matching sessions are left alone and failed updates are not automatically retried. Applied settings are not rolled back when Codexometer closes. +Browser profile reviews require `--web --web-control`; quota pills open the +session's full detail beside its native Codex requests, with review and confirmation +before applying. Read-only web mode cannot apply profiles. Start with the session that needs you. A **WORKING** badge in its telemetry box blinks only its ball, keeping the text steady; completion and attention states take priority. Recent activity alone is not treated as proof of work. Compact and wide detail panels keep content headings such as **LAST REPLY** or **APPROVAL REQUEST**, while full detail puts the session status in its box title without repeating it in the body. diff --git a/main.go b/main.go index 53ead9f..fb125b1 100644 --- a/main.go +++ b/main.go @@ -44,7 +44,7 @@ func (d *demoFetcher) QuotaStepPolicy() []codex.QuotaStep { } func (d *demoFetcher) QuotaSessions(ctx context.Context) ([]codex.QuotaSession, error) { - return []codex.QuotaSession{{ID: "demo-alpha", Model: "gpt-5.6-sol", Effort: "high"}, {ID: "demo-bravo", Model: "gpt-5.6-sol", Effort: "high"}}, ctx.Err() + return []codex.QuotaSession{{ID: "019d-demo-a1b2c", Model: "gpt-5.6-sol", Effort: "high"}, {ID: "019d-demo-d4e5f", Model: "gpt-5.6-sol", Effort: "high"}}, ctx.Err() } func (d *demoFetcher) ApplyQuotaProfile(ctx context.Context, targets []codex.QuotaSession, step codex.QuotaStep) (int, error) { @@ -385,7 +385,7 @@ func run(args []string, stdout, stderr io.Writer, deps dependencies) int { 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") webMode = flags.Bool("web", false, "serve the experimental read-only browser interface on loopback") - webControl = flags.Bool("web-control", false, "enable experimental browser session approvals and prompts (requires --web)") + webControl = flags.Bool("web-control", false, "enable experimental browser session approvals, prompts and configured quota profiles (requires --web)") webPort = flags.Int("web-port", 0, "local web port (0 chooses an available port; requires --web)") 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)") @@ -419,8 +419,8 @@ func run(args []string, stdout, stderr io.Writer, deps dependencies) int { fmt.Fprintln(stderr, "codexometer: --web cannot be combined with --inline, --check-auth or --digbench-game") return 2 } - if *webMode && len(quotaSteps) > 0 { - fmt.Fprintln(stderr, "codexometer: --quota-step-down is currently available only in the terminal UI") + if *webMode && len(quotaSteps) > 0 && !*webControl { + fmt.Fprintln(stderr, "codexometer: --quota-step-down in web mode requires --web-control") return 2 } if *resetThreshold < 0 || *resetThreshold > 100 { diff --git a/main_test.go b/main_test.go index 3189515..fba6d77 100644 --- a/main_test.go +++ b/main_test.go @@ -125,6 +125,15 @@ func TestQuotaStepModes(t *testing.T) { } } +func TestReadOnlyWebRejectsQuotaProfiles(t *testing.T) { + for _, mode := range []string{"ask", "auto"} { + var out, err bytes.Buffer + if code := run([]string{"--web", "--quota-step-down", "80:model:medium::" + mode}, &out, &err, dependencies{}); code != 2 || !strings.Contains(err.String(), "requires --web-control") { + t.Fatalf("read-only accepted policy: %d %s", code, err.String()) + } + } +} + func TestRunAuthCheckSuccessAndFailure(t *testing.T) { var stdout, stderr bytes.Buffer deps := dependencies{ diff --git a/web/src/SessionActions.svelte b/web/src/SessionActions.svelte index 8e6f65f..4bcfc04 100644 --- a/web/src/SessionActions.svelte +++ b/web/src/SessionActions.svelte @@ -4,8 +4,10 @@ let { session, observedCommand = '', - }: { session: string; observedCommand?: string } = $props(); + review = '', + }: { session: string; observedCommand?: string; review?: string } = $props(); interface Offer { + profile?: { threshold: number; current: string; proposed: string }; id: string; session: string; thread: string; @@ -59,7 +61,7 @@ ], ); let valid = $derived( - offer?.kind === 'approval' + offer?.kind === 'approval' || offer?.kind === 'profile' ? choice !== null : answers.length === questions.length && answers.every( @@ -82,7 +84,7 @@ try { const next = await controlRequest( 'offer', - { session }, + { session, ...(review ? { review } : {}) }, controller.signal, ); if (controller.signal.aborted) return; @@ -129,8 +131,9 @@ 'prepare', { session, + ...(review ? { review } : {}), offer: id, - ...(offer.kind === 'approval' + ...(offer.kind === 'approval' || offer.kind === 'profile' ? { choice } : { answers: [...answers] }), }, @@ -159,11 +162,21 @@ try { await controlRequest( 'commit', - { session, offer: id, confirmation: ticket }, + { + session, + ...(review ? { review } : {}), + offer: id, + confirmation: ticket, + }, controller.signal, ); success = true; - notice = kind === 'approval' ? 'Decision sent.' : 'Text sent.'; + notice = + kind === 'profile' + ? 'Profile decision completed.' + : kind === 'approval' + ? 'Decision sent.' + : 'Text sent.'; } catch { notice = 'Outcome uncertain. Check Codex before taking another action; nothing was retried.'; @@ -176,14 +189,31 @@
        -

        SESSION CONTROL // EXPERIMENTAL

        +

        + {review === 'profile' + ? 'QUOTA THRESHOLD' + : 'SESSION CONTROL // EXPERIMENTAL'} +

        + {#if offer?.profile && !stale && !offerError} +

        + Your {offer.profile.threshold}% quota threshold has been reached. Review + the profile for subsequent turns. +

        +

        CURRENT PROFILE

        +

        MODEL / REASONING LEVEL / SPEED

        +
        {offer.profile.current}
        +

        PROPOSED PROFILE

        +

        MODEL / REASONING LEVEL / SPEED

        +
        {offer.profile.proposed}
        +

        Applied settings remain after Codexometer closes.

        + {/if} {#if notice}

        {notice}

        {/if} {#if command}

        {actionableCommand ? 'EXACT COMMAND' : 'LAST OBSERVED COMMAND'}

        {command}
        - {:else if status === 'APPROVAL NEEDED' && !success} + {:else if review !== 'profile' && status === 'APPROVAL NEEDED' && !success}

        Command unavailable from this observation. Open Codex to inspect the request. @@ -220,16 +250,16 @@

        {offer.kind === 'approval' + >{offer.kind === 'approval' || offer.kind === 'profile' ? 'Choose a decision' : 'Reply to this session'} - {#if offer.kind === 'approval'} + {#if offer.kind === 'approval' || offer.kind === 'profile'} {#each offer.choices || [] as option, index}
        {#if confirming}

        - Check the target and {offer.kind === 'approval' - ? 'exact command and permission scope' - : 'message above'}. This will send to Codex; it may start work using + Check the target and {offer.kind === 'profile' + ? 'profile above' + : offer.kind === 'approval' + ? 'exact command and permission scope' + : 'message above'}. This will send to Codex; it may start work using your quota. Confirmation expires in {Math.max( 0, Math.ceil((expires - now) / 1000), )}s.

        diff --git a/web/src/Sessions.svelte b/web/src/Sessions.svelte index 11d3d73..0680b90 100644 --- a/web/src/Sessions.svelte +++ b/web/src/Sessions.svelte @@ -12,6 +12,18 @@ import SessionCopy from './SessionCopy.svelte'; let { params = {} }: { params?: { id?: string } } = $props(); let sessions = $derived(live.data?.sessions || []); + let profiles = $derived(live.data?.control ? live.data.profiles || [] : []); + function openProfile(id: string) { + select(id); + // Routing completes after the click; keep the native composer mounted. + setTimeout( + () => + document + .getElementById('quota-profile-' + id) + ?.scrollIntoView({ block: 'nearest' }), + 100, + ); + } // Every entry route (links, arrows, deep links and browser Forward) leaves a // wide row behind, so native browser Back agrees with Escape/All Sessions. $effect(() => { @@ -182,7 +194,11 @@ Session connection or refresh unavailable. Context and telemetry may be stale.

        {/if} -{#if attention.length && !stale}