From abb46f1b73e19f7c892daf4864b032c1bfb436da Mon Sep 17 00:00:00 2001 From: Robert <35533304+merefield@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:43:44 +0100 Subject: [PATCH 1/3] Add opt-in quota profiles with per-session approval and restoration --- README.md | 42 +++ internal/codex/client.go | 3 + internal/codex/daemon_status_unix.go | 34 +- internal/codex/quota_step.go | 60 +++ internal/codex/session_settings_unix.go | 346 ++++++++++++++++++ internal/codex/session_settings_unix_test.go | 317 ++++++++++++++++ internal/ui/model.go | 89 ++++- internal/ui/quota_reset.go | 21 +- internal/ui/quota_step_down.go | 362 +++++++++++++++++++ internal/ui/quota_step_down_test.go | 211 +++++++++++ internal/ui/view.go | 3 + main.go | 139 ++++++- main_test.go | 27 ++ 13 files changed, 1627 insertions(+), 27 deletions(-) create mode 100644 internal/codex/quota_step.go create mode 100644 internal/codex/session_settings_unix.go create mode 100644 internal/codex/session_settings_unix_test.go create mode 100644 internal/ui/quota_step_down.go create mode 100644 internal/ui/quota_step_down_test.go diff --git a/README.md b/README.md index bd86761..6a4a292 100644 --- a/README.md +++ b/README.md @@ -2045,6 +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) --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 @@ -2061,8 +2062,49 @@ codexometer --inline # Use a separately installed Codex build codexometer --codex ~/bin/codex + +# Ask before lowering model, reasoning and speed as weekly quota is consumed +codexometer \ + --quota-step-down 80:gpt-5.6-sol:medium:standard \ + --quota-step-down 95:gpt-5.6-luna:medium:slow ``` +### Quota step-down profiles + +`--quota-step-down PERCENT:MODEL:EFFORT[:SPEED]` 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 +`slow` are resolved against the model's advertised service tiers; `slow` is +**not** assumed to mean Flex. Unsupported models, reasoning levels and speeds +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. +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. + +Acceptance and decline are process-local: restarting Codexometer with the same +options presents eligible gates again, separately for each session and threshold. +Refreshes discover sessions but never reapply a profile or undo manual changes. +At a quota-window/account change and on clean exit, Codexometer attempts to +restore settings it changed, preserving fields subsequently changed manually. +Shutdown drains in-flight updates and rejects later writes before restoration. +Restoration errors are collected across sessions; unloaded sessions, daemon +failures, crashes and forced termination may require manual restoration. Session +updates are not an atomic transaction with concurrent Codex UI changes. +The feature does not edit +`config.toml` or change global Codex defaults and requires a Codex version that +exposes the shared app-server control socket and `thread/settings/update`. + ## Experimental browser interface Keep the terminal experience, or opt into a local Svelte browser dashboard: diff --git a/internal/codex/client.go b/internal/codex/client.go index 6d5c401..93de21f 100644 --- a/internal/codex/client.go +++ b/internal/codex/client.go @@ -21,6 +21,9 @@ const requestTimeout = 15 * time.Second type Client struct { Binary string LiveUsage *LiveUsageReader + // QuotaSteps is an opt-in launch-time policy for lowering the model profile + // of loaded sessions as quota consumption crosses configured thresholds. + QuotaSteps []QuotaStep // BenchmarkAPIKey is used only by isolated benchmark app-server sessions. // Fetch and local monitoring continue to use the prevailing Codex login. BenchmarkAPIKey string diff --git a/internal/codex/daemon_status_unix.go b/internal/codex/daemon_status_unix.go index 882aca0..94c7713 100644 --- a/internal/codex/daemon_status_unix.go +++ b/internal/codex/daemon_status_unix.go @@ -25,18 +25,28 @@ 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 - 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 + settingsMu sync.Mutex + settingsClosed bool + originalSettings map[string]quotaOwnership + writeMu sync.Mutex +} + +type quotaOwnership struct { + pending bool + original QuotaSession + applied QuotaSession + tierChanged bool } type daemonTurnKey struct { diff --git a/internal/codex/quota_step.go b/internal/codex/quota_step.go new file mode 100644 index 0000000..f246b70 --- /dev/null +++ b/internal/codex/quota_step.go @@ -0,0 +1,60 @@ +package codex + +import ( + "context" + "errors" +) + +type QuotaStep struct { + Threshold int + Model string + Effort string + // An advertised tier name or ID; empty preserves speed. + ServiceTier string +} +type QuotaSession struct { + ID string + Model string + Effort string + Tier *string +} +type QuotaStepPolicyProvider interface{ QuotaStepPolicy() []QuotaStep } +type SessionSettingsClient interface { + QuotaSessions(context.Context) ([]QuotaSession, error) + ApplyQuotaProfile(context.Context, []QuotaSession, QuotaStep) (int, error) + RestoreSessionSettings(context.Context) (int, error) + CloseQuotaProfiles(context.Context) (int, error) +} + +func (c Client) QuotaStepPolicy() []QuotaStep { return append([]QuotaStep(nil), c.QuotaSteps...) } +func (c Client) settingsClient() SessionSettingsClient { + if c.LiveUsage != nil { + p, _ := c.LiveUsage.statusProvider.(SessionSettingsClient) + return p + } + return nil +} +func (c Client) QuotaSessions(ctx context.Context) ([]QuotaSession, error) { + if p := c.settingsClient(); p != nil { + return p.QuotaSessions(ctx) + } + return nil, errors.New("shared session control unavailable") +} +func (c Client) ApplyQuotaProfile(ctx context.Context, targets []QuotaSession, step QuotaStep) (int, error) { + if p := c.settingsClient(); p != nil { + return p.ApplyQuotaProfile(ctx, targets, step) + } + return 0, errors.New("shared session control unavailable") +} +func (c Client) RestoreSessionSettings(ctx context.Context) (int, error) { + if p := c.settingsClient(); p != nil { + return p.RestoreSessionSettings(ctx) + } + return 0, nil +} +func (c Client) CloseQuotaProfiles(ctx context.Context) (int, error) { + if p := c.settingsClient(); p != nil { + return p.CloseQuotaProfiles(ctx) + } + return 0, nil +} diff --git a/internal/codex/session_settings_unix.go b/internal/codex/session_settings_unix.go new file mode 100644 index 0000000..fee943e --- /dev/null +++ b/internal/codex/session_settings_unix.go @@ -0,0 +1,346 @@ +//go:build unix + +package codex + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" +) + +func tierEqual(a, b *string) bool { return a == nil && b == nil || a != nil && b != nil && *a == *b } +func sameQuotaSettings(a, b QuotaSession) bool { + return a.Model == b.Model && a.Effort == b.Effort && tierEqual(a.Tier, b.Tier) +} + +// 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) { + var response struct { + Model string + ReasoningEffort string + ServiceTier json.RawMessage + Thread struct { + Model string + ReasoningEffort string + } + } + err := p.request(ctx, "thread/resume", map[string]any{"threadId": id, "excludeTurns": true}, &response) + s := QuotaSession{ID: id, Model: response.Model, Effort: response.ReasoningEffort} + if s.Model == "" { + s.Model = response.Thread.Model + } + if s.Effort == "" { + s.Effort = response.Thread.ReasoningEffort + } + if err != nil { + return s, err + } + if s.Model == "" || s.Effort == "" || len(response.ServiceTier) == 0 { + return s, errors.New("restorable model, effort or service tier unavailable") + } + if err = json.Unmarshal(response.ServiceTier, &s.Tier); err != nil { + return s, err + } + return s, nil +} + +func (p *daemonStatusProvider) QuotaSessions(ctx context.Context) ([]QuotaSession, error) { + p.settingsMu.Lock() + defer p.settingsMu.Unlock() + if p.settingsClosed { + return nil, errors.New("quota controller is closed") + } + if err := ctx.Err(); err != nil { + return nil, err + } + if err := p.ensureConnected(ctx); err != nil { + return nil, err + } + loaded, err := p.loadedThreads(ctx) + if err != nil { + return nil, err + } + ids := make([]string, 0, len(loaded)) + for id := range loaded { + ids = append(ids, id) + } + sort.Strings(ids) + var sessions []QuotaSession + var failures []error + for _, id := range ids { + s, err := p.readQuotaSession(ctx, id) + if err != nil { + failures = append(failures, fmt.Errorf("%s: %w", id, err)) + continue + } + sessions = append(sessions, s) + } + return sessions, errors.Join(failures...) +} + +// Resolve a requested speed against the model catalogue. "slow" is a name, +// never an assumed alias for flex; explicit standard routing is a sentinel. +func (p *daemonStatusProvider) validateQuotaStep(ctx context.Context, step QuotaStep) (QuotaStep, error) { + var cursor any + seen := map[string]bool{} + for { + var response struct { + Data []struct { + Model string + SupportedReasoningEfforts []struct{ ReasoningEffort string } + ServiceTiers []struct { + ID string + Name string + } + } + NextCursor *string + } + params := map[string]any{} + if cursor != nil { + params["cursor"] = cursor + } + if err := p.request(ctx, "model/list", params, &response); err != nil { + return step, err + } + for _, model := range response.Data { + if model.Model != step.Model { + continue + } + effortOK := false + for _, effort := range model.SupportedReasoningEfforts { + if effort.ReasoningEffort == step.Effort { + effortOK = true + } + } + if !effortOK { + return step, errors.New("model does not advertise requested reasoning effort") + } + if step.ServiceTier == "" || step.ServiceTier == "default" { + return step, nil + } + for _, tier := range model.ServiceTiers { + if tier.ID == step.ServiceTier || strings.EqualFold(tier.Name, step.ServiceTier) { + step.ServiceTier = tier.ID + return step, nil + } + } + return step, errors.New("model does not advertise requested speed tier") + } + if response.NextCursor == nil || seen[*response.NextCursor] { + break + } + seen[*response.NextCursor] = true + cursor = *response.NextCursor + } + return step, errors.New("requested model not advertised") +} + +// 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 { + if err := ctx.Err(); err != nil { + return err + } + if err := p.request(ctx, "thread/settings/update", params, nil); err != nil { + return err + } + deadline := time.NewTimer(3 * time.Second) + defer deadline.Stop() + for { + loaded, err := p.loadedThreads(ctx) + if err != nil { + return err + } + if _, ok := loaded[expected.ID]; !ok { + return errors.New("thread unloaded before settings could be verified") + } + current, err := p.readQuotaSession(ctx, expected.ID) + if err != nil { + return err + } + if sameQuotaSettings(current, expected) { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-deadline.C: + return errors.New("settings queued but application not verified") + case <-time.After(25 * time.Millisecond): + } + } +} + +func (p *daemonStatusProvider) ApplyQuotaProfile(ctx context.Context, targets []QuotaSession, step QuotaStep) (int, error) { + p.settingsMu.Lock() + defer p.settingsMu.Unlock() + if p.settingsClosed { + return 0, errors.New("quota controller is closed") + } + if err := ctx.Err(); err != nil { + return 0, err + } + if len(targets) == 0 { + return 0, nil + } + if err := p.ensureConnected(ctx); err != nil { + return 0, err + } + step, err := p.validateQuotaStep(ctx, step) + if err != nil { + return 0, err + } + if p.originalSettings == nil { + p.originalSettings = map[string]quotaOwnership{} + } + var failures []error + updated := 0 + for _, target := range targets { + if owner, ok := p.originalSettings[target.ID]; ok && owner.pending { + failures = append(failures, fmt.Errorf("%s: prior update uncertain; restore before another profile", target.ID)) + continue + } + if err := ctx.Err(); err != nil { + failures = append(failures, err) + break + } + loaded, err := p.loadedThreads(ctx) + if err != nil { + failures = append(failures, err) + break + } + if _, ok := loaded[target.ID]; !ok { + failures = append(failures, fmt.Errorf("%s: no longer loaded", target.ID)) + continue + } + current, err := p.readQuotaSession(ctx, target.ID) + if err != nil { + failures = append(failures, fmt.Errorf("%s: %w", target.ID, err)) + continue + } + if !sameQuotaSettings(current, target) { + failures = append(failures, fmt.Errorf("%s: settings changed since review; skipped", target.ID)) + continue + } + desired := current + desired.Model = step.Model + desired.Effort = step.Effort + params := map[string]any{"threadId": target.ID, "model": step.Model, "effort": step.Effort} + if step.ServiceTier != "" { + if step.ServiceTier == "default" { + desired.Tier = nil + params["serviceTier"] = nil + } else { + tier := step.ServiceTier + desired.Tier = &tier + params["serviceTier"] = tier + } + } + if sameQuotaSettings(current, desired) { + updated++ + continue + } + owner, exists := p.originalSettings[target.ID] + if !exists { + owner.original = current + } else { + // Rebase fields changed manually since our last write. + if current.Model != owner.applied.Model { + owner.original.Model = current.Model + } + if current.Effort != owner.applied.Effort { + owner.original.Effort = current.Effort + } + if !tierEqual(current.Tier, owner.applied.Tier) { + owner.original.Tier = current.Tier + owner.tierChanged = false + } + } + owner.applied = desired + owner.tierChanged = owner.tierChanged || step.ServiceTier != "" + owner.pending = true + // Keep recovery information even when the write outcome is uncertain. + p.originalSettings[target.ID] = owner + if err := p.writeQuotaSettings(ctx, desired, params); err != nil { + failures = append(failures, fmt.Errorf("%s: %w", target.ID, err)) + continue + } + owner.pending = false + p.originalSettings[target.ID] = owner + updated++ + } + return updated, errors.Join(failures...) +} + +func (p *daemonStatusProvider) RestoreSessionSettings(ctx context.Context) (int, error) { + p.settingsMu.Lock() + defer p.settingsMu.Unlock() + return p.restoreQuotaLocked(ctx) +} +func (p *daemonStatusProvider) CloseQuotaProfiles(ctx context.Context) (int, error) { + p.settingsMu.Lock() + defer p.settingsMu.Unlock() + // Waiting for the lock drains running writes; queued writes must reject. + p.settingsClosed = true + return p.restoreQuotaLocked(ctx) +} +func (p *daemonStatusProvider) restoreQuotaLocked(ctx context.Context) (int, error) { + if len(p.originalSettings) == 0 { + return 0, nil + } + if err := p.ensureConnected(ctx); err != nil { + return 0, err + } + loaded, err := p.loadedThreads(ctx) + if err != nil { + return 0, err + } + restored := 0 + var failures []error + for id, owner := range p.originalSettings { + if _, ok := loaded[id]; !ok { + failures = append(failures, fmt.Errorf("%s: unloaded; restore manually", id)) + continue + } + current, err := p.readQuotaSession(ctx, id) + if err != nil { + failures = append(failures, fmt.Errorf("%s: %w", id, err)) + continue + } + desired, params := quotaRestoration(owner, current) + // Compensate an uncertain queued write even if the old settings still read + // back: the acknowledged restore is ordered after our original update. + if !sameQuotaSettings(current, desired) || owner.pending && len(params) > 1 { + if err := p.writeQuotaSettings(ctx, desired, params); err != nil { + failures = append(failures, fmt.Errorf("%s: %w", id, err)) + continue + } + } + delete(p.originalSettings, id) + restored++ + } + return restored, errors.Join(failures...) +} + +func quotaRestoration(owner quotaOwnership, current QuotaSession) (QuotaSession, map[string]any) { + desired := current + params := map[string]any{"threadId": current.ID} + if owner.original.Model != owner.applied.Model && (current.Model == owner.applied.Model || owner.pending && current.Model == owner.original.Model) { + desired.Model = owner.original.Model + params["model"] = desired.Model + } + if owner.original.Effort != owner.applied.Effort && (current.Effort == owner.applied.Effort || owner.pending && current.Effort == owner.original.Effort) { + desired.Effort = owner.original.Effort + params["effort"] = desired.Effort + } + if owner.tierChanged && (tierEqual(current.Tier, owner.applied.Tier) || owner.pending && tierEqual(current.Tier, owner.original.Tier)) { + desired.Tier = owner.original.Tier + params["serviceTier"] = desired.Tier + } + return desired, params +} diff --git a/internal/codex/session_settings_unix_test.go b/internal/codex/session_settings_unix_test.go new file mode 100644 index 0000000..bfbebaa --- /dev/null +++ b/internal/codex/session_settings_unix_test.go @@ -0,0 +1,317 @@ +//go:build unix + +package codex + +import ( + "context" + "errors" + "github.com/gorilla/websocket" + "net" + "net/http" + "os" + "path/filepath" + "sync" + "testing" + "time" +) + +type quotaDaemonFixture struct { + mu sync.Mutex + sessions map[string]QuotaSession + writes []map[string]any + fail string + queued bool + started chan struct{} + release chan struct{} +} + +func newQuotaDaemon(t *testing.T) (*daemonStatusProvider, *quotaDaemonFixture) { + t.Helper() + fixture := "aDaemonFixture{sessions: map[string]QuotaSession{"one": {ID: "one", Model: "large", Effort: "high"}, "two": {ID: "two", Model: "large", Effort: "high"}}} + dir, err := os.MkdirTemp("", "cxq-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Remove(dir) }) + socket := filepath.Join(dir, "q.sock") + listener, err := net.Listen("unix", socket) + if err != nil { + if errors.Is(err, os.ErrPermission) { + t.Skip("sandbox does not permit Unix-domain socket listeners") + } + t.Fatal(err) + } + upgrader := websocket.Upgrader{} + server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + for { + var req struct { + ID *int64 + Method string + Params map[string]any + } + if conn.ReadJSON(&req) != nil { + return + } + if req.ID == nil { + continue + } + fixture.mu.Lock() + result := map[string]any{} + failure := false + id, _ := req.Params["threadId"].(string) + switch req.Method { + case "initialize": + result["userAgent"] = "test" + case "model/list": + result["data"] = []any{map[string]any{"model": "small", "supportedReasoningEfforts": []any{map[string]any{"reasoningEffort": "medium"}}, "serviceTiers": []any{map[string]any{"id": "flex", "name": "Fast"}, map[string]any{"id": "priority", "name": "Slow"}}}} + case "thread/loaded/list": + ids := []string{} + for id := range fixture.sessions { + ids = append(ids, id) + } + result["data"] = ids + case "thread/resume": + s, ok := fixture.sessions[id] + failure = !ok || fixture.fail == id + 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 + } + if v, ok := req.Params["serviceTier"]; ok { + s.Tier = nil + if value, ok := v.(string); ok { + s.Tier = &value + } + } + fixture.sessions[id] = s + } + if fixture.started != nil { + close(fixture.started) + fixture.started = nil + release := fixture.release + fixture.mu.Unlock() + <-release + fixture.mu.Lock() + } + default: + failure = true + } + fixture.mu.Unlock() + response := map[string]any{"id": *req.ID, "result": result} + if failure { + delete(response, "result") + response["error"] = map[string]any{"code": -1, "message": "test failure"} + } + if conn.WriteJSON(response) != nil { + return + } + } + })} + go server.Serve(listener) + p := &daemonStatusProvider{socketPath: socket} + t.Cleanup(func() { p.disconnect(nil); server.Close() }) + return p, fixture +} +func TestQuotaSettingsCatalogueDriftAndRestoration(t *testing.T) { + p, f := newQuotaDaemon(t) + ctx := context.Background() + sessions, err := p.QuotaSessions(ctx) + if err != nil || len(sessions) != 2 { + t.Fatalf("scan %v %v", sessions, err) + } + n, err := p.ApplyQuotaProfile(ctx, sessions, QuotaStep{Model: "small", Effort: "medium", ServiceTier: "slow"}) + if err != nil || n != 2 { + t.Fatalf("apply %d %v", n, err) + } + f.mu.Lock() + if *f.sessions["one"].Tier != "priority" { + t.Error("slow did not resolve by catalogue name") + } + s := f.sessions["one"] + s.Model = "manual" + s.Tier = nil + f.sessions["one"] = s + f.fail = "two" + f.mu.Unlock() + n, err = p.RestoreSessionSettings(ctx) + if n != 1 || err == nil { + t.Fatalf("partial restore %d %v", n, err) + } + f.mu.Lock() + s = f.sessions["one"] + f.fail = "" + f.mu.Unlock() + if s.Model != "manual" || s.Effort != "high" || s.Tier != nil { + t.Fatalf("manual settings clobbered: %#v", s) + } + n, err = p.CloseQuotaProfiles(ctx) + if n != 1 || err != nil { + t.Fatalf("close %d %v", n, err) + } + if _, err = p.ApplyQuotaProfile(ctx, sessions, QuotaStep{Model: "small", Effort: "medium"}); err == nil { + t.Fatal("write after close accepted") + } +} +func TestQuotaSettingsRejectUnsupportedAndChangedTargets(t *testing.T) { + p, f := newQuotaDaemon(t) + ctx := context.Background() + sessions, err := p.QuotaSessions(ctx) + if err != nil { + t.Fatal(err) + } + for _, step := range []QuotaStep{{Model: "missing", Effort: "medium"}, {Model: "small", Effort: "ultra"}, {Model: "small", Effort: "medium", ServiceTier: "unknown"}} { + if _, err := p.ApplyQuotaProfile(ctx, sessions, step); err == nil { + t.Fatalf("accepted %#v", step) + } + } + f.mu.Lock() + s := f.sessions["one"] + s.Effort = "low" + f.sessions["one"] = s + delete(f.sessions, "two") + f.mu.Unlock() + n, err := p.ApplyQuotaProfile(ctx, sessions, QuotaStep{Model: "small", Effort: "medium"}) + if n != 0 || err == nil { + t.Fatalf("drift %d %v", n, err) + } + f.mu.Lock() + defer f.mu.Unlock() + if len(f.writes) != 0 { + t.Fatal("wrote unapproved settings") + } +} +func TestQuotaSettingsPreservesUnownedSpeedAndStandardClearsTier(t *testing.T) { + p, f := newQuotaDaemon(t) + ctx := context.Background() + sessions, _ := p.QuotaSessions(ctx) + if _, err := p.ApplyQuotaProfile(ctx, sessions, QuotaStep{Model: "small", Effort: "medium"}); err != nil { + t.Fatal(err) + } + tier := "flex" + f.mu.Lock() + s := f.sessions["one"] + s.Tier = &tier + f.sessions["one"] = s + f.mu.Unlock() + if _, err := p.RestoreSessionSettings(ctx); err != nil { + t.Fatal(err) + } + f.mu.Lock() + s = f.sessions["one"] + f.mu.Unlock() + if s.Tier == nil || *s.Tier != "flex" { + t.Fatal("unowned speed overwritten") + } + sessions, _ = p.QuotaSessions(ctx) + if _, err := p.ApplyQuotaProfile(ctx, sessions, QuotaStep{Model: "small", Effort: "medium", ServiceTier: "default"}); err != nil { + t.Fatal(err) + } + f.mu.Lock() + defer f.mu.Unlock() + if f.sessions["one"].Tier != nil { + t.Fatal("standard must clear tier") + } +} +func TestQuotaSettingsNoopCloseDoesNotConnect(t *testing.T) { + p := &daemonStatusProvider{socketPath: "/missing/no-socket"} + if n, err := p.CloseQuotaProfiles(context.Background()); n != 0 || err != nil { + t.Fatalf("noop close %d %v", n, err) + } +} + +func TestQuotaRestorationOnlyTouchesOwnedUnmodifiedFields(t *testing.T) { + tier := "flex" + manualTier := "priority" + original := QuotaSession{ID: "one", Model: "large", Effort: "high"} + applied := QuotaSession{ID: "one", Model: "small", Effort: "medium", Tier: &tier} + owner := quotaOwnership{original: original, applied: applied, tierChanged: true} + restored, params := quotaRestoration(owner, applied) + if !sameQuotaSettings(restored, original) || len(params) != 4 { + t.Fatalf("full restore %#v %#v", restored, params) + } + current := applied + current.Model = "manual" + current.Tier = &manualTier + restored, params = quotaRestoration(owner, current) + if restored.Model != "manual" || restored.Effort != "high" || !tierEqual(restored.Tier, &manualTier) || len(params) != 2 { + t.Fatalf("manual restore %#v %#v", restored, params) + } + owner.tierChanged = false + restored, params = quotaRestoration(owner, applied) + if _, ok := params["serviceTier"]; ok || !tierEqual(restored.Tier, applied.Tier) { + t.Fatal("unowned speed overwritten") + } + owner.pending = true + _, params = quotaRestoration(owner, original) + if params["model"] != "large" || params["effort"] != "high" { + t.Fatal("uncertain write has no compensating restore") + } +} +func TestQuotaSettingsCloseDrainsInflightWrite(t *testing.T) { + p, f := newQuotaDaemon(t) + ctx := context.Background() + sessions, _ := p.QuotaSessions(ctx) + started, release := make(chan struct{}), make(chan struct{}) + f.mu.Lock() + f.started = started + f.release = release + f.mu.Unlock() + applied := make(chan error, 1) + closed := make(chan error, 1) + go func() { + _, err := p.ApplyQuotaProfile(ctx, sessions[:1], QuotaStep{Model: "small", Effort: "medium"}) + applied <- err + }() + <-started + go func() { _, err := p.CloseQuotaProfiles(ctx); closed <- err }() + select { + case <-closed: + t.Fatal("close raced the write") + case <-time.After(25 * time.Millisecond): + } + close(release) + if err := <-applied; err != nil { + t.Fatal(err) + } + if err := <-closed; err != nil { + t.Fatal(err) + } + f.mu.Lock() + defer f.mu.Unlock() + if f.sessions["one"].Model != "large" { + t.Fatal("inflight profile survived close") + } +} +func TestQuotaSettingsQueuedAcknowledgementIsNotSuccess(t *testing.T) { + p, f := newQuotaDaemon(t) + sessions, _ := p.QuotaSessions(context.Background()) + 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, context.DeadlineExceeded) { + t.Fatalf("unverified write %d %v", n, err) + } + if _, err := p.CloseQuotaProfiles(context.Background()); err != nil { + t.Fatal(err) + } + f.mu.Lock() + defer f.mu.Unlock() + if len(f.writes) != 2 || f.writes[1]["model"] != "large" { + t.Fatalf("missing queued compensation: %#v", f.writes) + } +} diff --git a/internal/ui/model.go b/internal/ui/model.go index 718bf31..1c648d9 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -72,6 +72,14 @@ type Model struct { resetScroll int resetConfirmUntil time.Time resetRevision uint64 + quotaSteps []codex.QuotaStep + quota quotaControl + quotaStepPending *codex.QuotaStep + quotaStepActive *codex.QuotaStep + quotaStepWindow string + quotaStepBusy bool + quotaStepNotice string + quotaStepConfirmUntil time.Time fetcher Fetcher usageFetcher TokenUsageFetcher refreshEvery time.Duration @@ -419,6 +427,9 @@ func New(fetcher Fetcher, refreshEvery time.Duration) Model { quotaAPIIssues: make(map[string]string), appVersion: version.Current(), } + if provider, ok := fetcher.(codex.QuotaStepPolicyProvider); ok { + model.quotaSteps = provider.QuotaStepPolicy() + } if usageFetcher, ok := fetcher.(TokenUsageFetcher); ok { model.usageFetcher = usageFetcher } @@ -499,7 +510,63 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } m.loading = true return m, m.fetch() + case quotaScanResult: + if message.revision != m.quota.revision { + return m, nil + } + m.quotaStepBusy = false + // Any newly read inventory invalidates an armed confirmation. + m.clearQuotaConfirmation() + m.quota.sessions = message.sessions + if message.err != nil { + m.quotaStepNotice = "Some sessions unavailable: " + message.err.Error() + } + return m, nil + case quotaStepResult: + if message.revision != m.quota.revision { + return m, nil + } + m.quotaStepBusy = false + if message.restoring { + m.quota.restoring = false + m.quotaStepActive = nil + m.quotaStepNotice = "Prior profile restoration finished." + if message.err != nil { + m.quotaStepNotice = "Restoration incomplete; check Codex: " + message.err.Error() + } + return m, nil + } + if message.window != m.quotaStepWindow { + return m, nil + } + 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.quota.sessions = nil // Re-read settings before another threshold can be approved. + m.quotaStepNotice = fmt.Sprintf("Verified %d of %d session updates. No automatic retries.", message.updated, len(message.targets)) + if message.updated > 0 { + step := message.step + m.quotaStepActive = &step + } + if message.err != nil { + m.quotaStepNotice += " Check Codex: " + message.err.Error() + } + 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 } @@ -743,6 +810,12 @@ 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 { @@ -873,6 +946,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { return m.pressFooterButton(button) } case tea.WindowSizeMsg: + m.clearQuotaConfirmation() m.width = message.Width m.height = message.Height m.prepareBenchmarkDetailTranscript() @@ -900,6 +974,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } m.loading = false m.err = message.err + var quotaStepCommand tea.Cmd if message.err == nil { if m.snapshot.AccountFingerprint != message.snapshot.AccountFingerprint { m.history.data = codex.AccountUsage{} @@ -909,6 +984,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } m.snapshot = message.snapshot m.lastRefresh = time.Now() + quotaStepCommand = m.evaluateQuotaStep(message.snapshot) if message.benchmarkQuotaRevision != m.benchmarkQuotaAccounting.revision || m.benchmarkQuotaAccounting.active { m.quotaAPITelemetryIssue = i18n.Text("OBSERVATION DEFERRED") } else if message.usageErr == nil && m.usageFetcher != nil { @@ -936,14 +1012,16 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { kind: monitorFetchStart, usage: message.usage, quota: message.snapshot, at: message.at, }) m = started.(Model) - return m, nil + return m, quotaStepCommand } - return m.beginMonitorFetch(monitorFetchStart) + next, command := m.beginMonitorFetch(monitorFetchStart) + return next, tea.Batch(command, quotaStepCommand) } if m.meterView == viewUsage && m.history.data.FetchedAt.IsZero() && m.history.err == nil { command := m.requestHistory() - return m, command + return m, tea.Batch(command, quotaStepCommand) } + return m, quotaStepCommand case secondMsg: if m.monitorState == monitorRunning { m.refreshMonitorRates(time.Time(message), false) @@ -956,6 +1034,9 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.resetConfirmUntil = time.Time{} m.resetNotice = "" } + if !m.quotaStepConfirmUntil.IsZero() && time.Now().After(m.quotaStepConfirmUntil) { + m.clearQuotaConfirmation() + } m.phase++ commands := []tea.Cmd{secondTick()} if m.monitorState == monitorRunning { @@ -1132,6 +1213,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } func (m Model) pressViewTab(view meterViewID) (tea.Model, tea.Cmd) { + m.clearQuotaConfirmation() if view != m.meterView { m.monitorApprovalConfirm = "" } @@ -1406,6 +1488,7 @@ 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 } diff --git a/internal/ui/quota_reset.go b/internal/ui/quota_reset.go index 993b7fd..98e56f2 100644 --- a/internal/ui/quota_reset.go +++ b/internal/ui/quota_reset.go @@ -172,6 +172,14 @@ 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 +} + +func (m Model) baseResetControlsLayout(width int) resetControls { c := resetControls{tabsWidth: width, button: m.resetLabel()} if c.button == "" { return c @@ -206,7 +214,7 @@ func (m Model) resetControlsLayout(width int) resetControls { } func (m Model) resetLayout(width int) (int, string) { - c := m.resetControlsLayout(width) + c := m.baseResetControlsLayout(width) if c.extraRows > 0 { return width, "" } @@ -226,9 +234,9 @@ func (m Model) resetWarningAt(x, y int) bool { } func (m Model) renderResetControls(width int, tabs string, colors palette) string { - c := m.resetControlsLayout(width) + c := m.baseResetControlsLayout(width) if c.button == "" { - return tabs + return m.appendQuotaActions(tabs, width) } rows := []string{tabs} for range c.extraRows { @@ -242,17 +250,18 @@ 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 strings.Join(rows, "\n") + return m.appendQuotaActions(strings.Join(rows, "\n"), width) } func (m Model) resetOwnRow(width int) bool { - return m.resetControlsLayout(width).extraRows > 0 + return m.baseResetControlsLayout(width).extraRows > 0 } func (m Model) pressQuotaReset() (tea.Model, tea.Cmd) { - if m.resetBusy || m.resetLabel() == "" { + if m.resetBusy || m.quotaStepBusy || m.resetLabel() == "" { return m, nil } + m.clearQuotaConfirmation() consumer, supported := m.fetcher.(resetConsumer) if !supported { m.resetConfirmUntil = time.Time{} diff --git a/internal/ui/quota_step_down.go b/internal/ui/quota_step_down.go new file mode 100644 index 0000000..edb8706 --- /dev/null +++ b/internal/ui/quota_step_down.go @@ -0,0 +1,362 @@ +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" + "reflect" + "strings" + "time" +) + +type quotaControl struct { + revision uint64 + cancel context.CancelFunc + sessions []codex.QuotaSession + handled map[string]int + selected int + confirm []codex.QuotaSession + confirmStep codex.QuotaStep + confirmWindow string + confirmAll bool + restoring bool +} + +func (m Model) CancelQuotaWork() { + if m.quota.cancel != nil { + m.quota.cancel() + } +} + +type quotaScanResult struct { + revision uint64 + sessions []codex.QuotaSession + err error +} +type quotaStepResult struct { + revision uint64 + step codex.QuotaStep + window string + targets []codex.QuotaSession + updated int + err error + restoring bool +} + +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 != "" { + result += " / " + step.ServiceTier + } else { + result += " / speed unchanged" + } + 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 +} +func (m Model) quotaFresh() bool { + meter, window, ok := quotaPolicyWindow(m.snapshot) + return ok && 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() { + m.quota.confirm = nil + m.quotaStepConfirmUntil = time.Time{} +} +func (m *Model) evaluateQuotaStep(snapshot codex.Snapshot) tea.Cmd { + if len(m.quotaSteps) == 0 { + return nil + } + meter, window, ok := quotaPolicyWindow(snapshot) + if !ok { + m.clearQuotaConfirmation() + m.quotaStepPending = nil + return nil + } + if m.quotaStepWindow != "" && m.quotaStepWindow != window { + if m.quota.cancel != nil { + m.quota.cancel() + } + m.quota.revision++ + m.clearQuotaConfirmation() + m.quota.sessions = nil + m.quota.handled = nil + m.quotaStepPending = nil + m.quotaStepWindow = window + m.quotaStepBusy = true + m.quota.restoring = true + m.quotaStepNotice = "Quota window changed: restoring owned settings." + revision := m.quota.revision + return func() tea.Msg { + c, ok := m.fetcher.(codex.SessionSettingsClient) + if !ok { + return quotaStepResult{revision: revision, restoring: true} + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + n, err := c.RestoreSessionSettings(ctx) + return quotaStepResult{revision: revision, restoring: true, updated: n, err: err} + } + } + m.quotaStepWindow = window + var candidate *codex.QuotaStep + for _, step := range m.quotaSteps { + if step.Threshold <= meter.Window.UsedPercent { + copy := step + candidate = © + } + } + if !reflect.DeepEqual(candidate, m.quotaStepPending) { + m.clearQuotaConfirmation() + } + m.quotaStepPending = candidate + if candidate == nil || m.quotaStepBusy { + return nil + } + m.quotaStepBusy = true + m.quota.revision++ + revision := m.quota.revision + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + m.quota.cancel = cancel + return func() tea.Msg { + defer cancel() + c, ok := m.fetcher.(codex.SessionSettingsClient) + if !ok { + return quotaScanResult{revision: revision, err: fmt.Errorf("shared session control unavailable")} + } + sessions, err := c.QuotaSessions(ctx) + return quotaScanResult{revision: revision, sessions: sessions, err: err} + } +} +func (m Model) quotaCandidates() []codex.QuotaSession { + if m.quotaStepPending == nil { + return nil + } + var result []codex.QuotaSession + for _, s := range m.quota.sessions { + if m.quota.handled[s.ID] < 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() { + 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...) + m.quota.confirmStep = *m.quotaStepPending + m.quota.confirmAll = all + m.quota.confirmWindow = m.quotaStepWindow + m.quotaStepConfirmUntil = time.Now().Add(10 * time.Second) + m.quotaStepNotice = fmt.Sprintf("Review %d session(s). Repeat the SAME approval action to confirm; Esc cancels. New sessions are not included.", 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 = "Too many sessions to review together in this pane. Use G to approve individually." + } + return m, nil + } + step := m.quota.confirmStep + window := m.quota.confirmWindow + m.clearQuotaConfirmation() + m.quotaStepBusy = true + m.quota.revision++ + revision := m.quota.revision + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + m.quota.cancel = cancel + return m, func() tea.Msg { + defer cancel() + c, ok := m.fetcher.(codex.SessionSettingsClient) + if !ok { + return quotaStepResult{revision: revision, err: fmt.Errorf("shared session control 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() { + 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 = "Session skipped at this gate for this run." +} +func (m Model) quotaStepLabel() string { + if !m.meterView.isQuota() || len(m.quotaSteps) == 0 { + return "" + } + if m.quotaStepBusy { + return "QUOTA PROFILE: checking / updating…" + } + if len(m.quotaCandidates()) > 0 { + if len(m.quota.confirm) > 0 { + return "[G: CONFIRM ONE] [A: CONFIRM ALL] [N: NEXT] [D: SKIP]" + } + return "[G: REVIEW ONE] [A: REVIEW ALL] [N: NEXT] [D: SKIP]" + } + if m.quotaStepActive != nil { + return "QUOTA PROFILE: " + quotaStepProfile(*m.quotaStepActive) + " (approved sessions only)" + } + return "QUOTA PROFILE: waiting for threshold / new sessions" +} +func (m Model) renderQuotaStepNotice(width int) string { + c := paletteFor(m.theme) + body := m.quotaStepNotice + if body == "" { + body = m.quotaStepLabel() + } + candidates := m.quotaCandidates() + if len(candidates) > 0 { + s := candidates[m.quota.selected%len(candidates)] + speed := "unset" + if s.Tier != nil { + speed = *s.Tier + } + body += fmt.Sprintf("\nGate %d%% — Session %d/%d: %s\n%s / %s / %s → %s", m.quotaStepPending.Threshold, m.quota.selected%len(candidates)+1, len(candidates), s.ID, s.Model, s.Effort, speed, quotaStepProfile(*m.quotaStepPending)) + } + if len(m.quota.confirm) > 1 { + for _, s := range m.quota.confirm { + speed := "unset" + if s.Tier != nil { + speed = *s.Tier + } + body += "\n" + s.ID + ": " + s.Model + " / " + s.Effort + " / " + speed + } + } + return frame(width, "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.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 + } + } + 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 { + labels[0] = "[G: CONFIRM ONE]" + labels[1] = "[A: CONFIRM ALL]" + } + keys := []string{"g", "a", "n", "d"} + x, y := 0, 0 + var actions []quotaAction + for i, label := range labels { + if width < len(label) { + label = "[" + strings.ToUpper(keys[i]) + "]" + } + if x > 0 && x+len(label) > width { + x = 0 + y++ + } + actions = append(actions, quotaAction{keys[i], label, x, y}) + x += len(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-len(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+len(a.label) { + return a.key + } + } + return "" +} diff --git a/internal/ui/quota_step_down_test.go b/internal/ui/quota_step_down_test.go new file mode 100644 index 0000000..a24f33d --- /dev/null +++ b/internal/ui/quota_step_down_test.go @@ -0,0 +1,211 @@ +package ui + +import ( + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "context" + "errors" + "github.com/merefield/codexometer/internal/codex" + "strings" + "testing" + "time" +) + +type quotaStepTestFetcher struct { + steps []codex.QuotaStep + sessions []codex.QuotaSession + targets []codex.QuotaSession + updates, restores int +} + +func (f *quotaStepTestFetcher) Fetch(context.Context) (codex.Snapshot, error) { + return codex.Snapshot{}, nil +} +func (f *quotaStepTestFetcher) ConsumeReset(context.Context, string, string) (string, error) { + return "reset", nil +} +func (f *quotaStepTestFetcher) QuotaStepPolicy() []codex.QuotaStep { return f.steps } +func (f *quotaStepTestFetcher) QuotaSessions(context.Context) ([]codex.QuotaSession, error) { + return append([]codex.QuotaSession(nil), f.sessions...), nil +} +func (f *quotaStepTestFetcher) ApplyQuotaProfile(_ context.Context, targets []codex.QuotaSession, _ codex.QuotaStep) (int, error) { + f.updates++ + f.targets = targets + return len(targets), nil +} +func (f *quotaStepTestFetcher) RestoreSessionSettings(context.Context) (int, error) { + f.restores++ + return 1, nil +} +func (f *quotaStepTestFetcher) CloseQuotaProfiles(ctx context.Context) (int, error) { + return f.RestoreSessionSettings(ctx) +} +func quotaStepSnapshot(used int, reset int64) codex.Snapshot { + s := codex.DemoSnapshot() + s.AccountFingerprint = "account" + s.FetchedAt = time.Now() + s.RateLimits.Secondary.UsedPercent = used + s.RateLimits.Secondary.ResetsAt = &reset + return s +} +func quotaTestModel(t *testing.T) (Model, *quotaStepTestFetcher) { + t.Helper() + f := "aStepTestFetcher{steps: []codex.QuotaStep{{Threshold: 80, Model: "small", Effort: "medium"}, {Threshold: 95, Model: "small", Effort: "low"}}, sessions: []codex.QuotaSession{{ID: "one", Model: "large", Effort: "high"}, {ID: "two", Model: "large", Effort: "high"}}} + m := New(f, time.Minute) + m.width = 100 + m.height = 60 + m.loading = false + m.snapshot = quotaStepSnapshot(85, time.Now().Add(time.Hour).Unix()) + cmd := m.evaluateQuotaStep(m.snapshot) + if cmd == nil { + t.Fatal("no inventory scan") + } + 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 TestQuotaApprovalIsPerSessionAndNewSessionsNeedApproval(t *testing.T) { + m, f := quotaTestModel(t) + m, cmd := quotaPress(m, false) + if cmd != nil || len(m.quota.confirm) != 1 { + t.Fatal("first press must only review one session") + } + m, cmd = quotaPress(m, false) + if cmd == nil { + t.Fatal("second press should apply") + } + next, _ := m.Update(cmd()) + m = next.(Model) + if f.updates != 1 || len(f.targets) != 1 || f.targets[0].ID != "one" { + t.Fatalf("targets %#v", f.targets) + } + f.sessions = append(f.sessions, codex.QuotaSession{ID: "new", Model: "large", Effort: "high"}) + cmd = m.evaluateQuotaStep(m.snapshot) + next, _ = m.Update(cmd()) + m = next.(Model) + if f.updates != 1 || len(m.quotaCandidates()) != 2 { + t.Fatal("refresh must only discover unapproved sessions") + } + restarted := New(f, time.Minute) + if len(restarted.quota.handled) != 0 { + t.Fatal("approval persisted") + } +} +func TestQuotaConfirmationRejectsStaleOrChangedSnapshots(t *testing.T) { + for _, kind := range []string{"stale", "loading", "resetting", "error", "expired window", "threshold", "inventory"} { + t.Run(kind, func(t *testing.T) { + m, f := quotaTestModel(t) + m, _ = quotaPress(m, false) + switch kind { + case "stale": + m.snapshot.FetchedAt = time.Now().Add(-time.Hour) + case "loading": + m.loading = true + case "resetting": + m.resetBusy = true + case "error": + m.err = errors.New("offline") + case "expired window": + reset := time.Now().Add(-time.Second).Unix() + m.snapshot.RateLimits.Secondary.ResetsAt = &reset + case "threshold": + m.snapshot.RateLimits.Secondary.UsedPercent = 96 + cmd := m.evaluateQuotaStep(m.snapshot) + next, _ := m.Update(cmd()) + m = next.(Model) + case "inventory": + cmd := m.evaluateQuotaStep(m.snapshot) + next, _ := m.Update(cmd()) + m = next.(Model) + } + _, cmd := quotaPress(m, false) + if cmd != nil || f.updates != 0 { + t.Fatal("changed confirmation was applied") + } + }) + } +} +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 TestQuotaWindowChangeRestoresAndIgnoresLateResults(t *testing.T) { + m, f := quotaTestModel(t) + m, _ = quotaPress(m, false) + m, apply := quotaPress(m, false) + old := apply().(quotaStepResult) + m.snapshot = quotaStepSnapshot(85, time.Now().Add(2*time.Hour).Unix()) + restore := m.evaluateQuotaStep(m.snapshot) + if restore == nil || !m.quotaStepBusy { + t.Fatal("window change did not restore") + } + next, _ := m.Update(old) + m = next.(Model) + if !m.quotaStepBusy { + t.Fatal("old result cleared busy state") + } + next, _ = m.Update(restore()) + m = next.(Model) + if f.restores != 1 || m.quotaStepActive != nil || len(m.quota.handled) != 0 { + t.Fatal("window state not reset") + } +} +func TestQuotaSkipAndControlsIndependentOfReset(t *testing.T) { + m, _ := quotaTestModel(t) + m.declineQuotaStep() + 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") + } + } +} + +func TestQuotaControlsKeepResetVisibleAndClickable(t *testing.T) { + m, _ := quotaTestModel(t) + m.snapshot.RateLimitResetCredits = &codex.ResetCredits{AvailableCount: 2} + 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") { + t.Fatalf("missing controls %q", rendered) + } + if lipgloss.Width(rendered) > width { + t.Fatalf("width %d overflow: %d", width, lipgloss.Width(rendered)) + } + g := m.dashboardLayout() + c := m.baseResetControlsLayout(g.contentWidth) + 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) + } + } + } +} diff --git a/internal/ui/view.go b/internal/ui/view.go index ac7771e..b658f4c 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -51,6 +51,9 @@ 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/main.go b/main.go index 3895ed5..fef549b 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,8 @@ import ( "io" "os" "os/signal" + "sort" + "strconv" "strings" "sync" "time" @@ -21,6 +23,8 @@ import ( ) type demoFetcher struct { + quotaSteps []codex.QuotaStep + settingsUpdates int approvalDecision string mu sync.Mutex snapshot codex.Snapshot @@ -36,6 +40,108 @@ type demoFetcher struct { bravoTurns []codex.LiveTurnTiming } +func (d *demoFetcher) QuotaStepPolicy() []codex.QuotaStep { + return append([]codex.QuotaStep(nil), d.quotaSteps...) +} + +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() +} + +func (d *demoFetcher) ApplyQuotaProfile(ctx context.Context, targets []codex.QuotaSession, step codex.QuotaStep) (int, error) { + if err := ctx.Err(); err != nil { + return 0, err + } + d.mu.Lock() + defer d.mu.Unlock() + d.settingsUpdates++ + return len(targets), nil +} + +func (d *demoFetcher) CloseQuotaProfiles(ctx context.Context) (int, error) { + return d.RestoreSessionSettings(ctx) +} + +func (d *demoFetcher) RestoreSessionSettings(ctx context.Context) (int, error) { + if err := ctx.Err(); err != nil { + return 0, err + } + d.mu.Lock() + defer d.mu.Unlock() + if d.settingsUpdates == 0 { + return 0, nil + } + d.settingsUpdates = 0 + return 2, nil +} + +type quotaStepFlags []codex.QuotaStep + +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 != "" { + value += ":" + quotaStepSpeedFlag(step.ServiceTier) + } + values = append(values, value) + } + return strings.Join(values, ",") +} + +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]") + } + threshold, err := strconv.Atoi(strings.TrimSpace(parts[0])) + if err != nil || threshold < 1 || threshold > 100 { + return fmt.Errorf("percentage must be between 1 and 100") + } + model, effort := strings.TrimSpace(parts[1]), strings.TrimSpace(parts[2]) + if model == "" { + return fmt.Errorf("model is required") + } + validEffort := false + for _, candidate := range []string{"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"} { + if effort == candidate { + validEffort = true + break + } + } + if !validEffort { + return fmt.Errorf("unsupported reasoning effort %q", effort) + } + for _, step := range *f { + if step.Threshold == threshold { + return fmt.Errorf("percentage %d is configured more than once", threshold) + } + } + serviceTier := "" + if len(parts) == 4 { + switch strings.ToLower(strings.TrimSpace(parts[3])) { + case "fast", "slow", "flex", "priority": + serviceTier = strings.ToLower(strings.TrimSpace(parts[3])) + case "standard", "default": + serviceTier = "default" + default: + 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}) + sort.Slice(*f, func(i, j int) bool { return (*f)[i].Threshold < (*f)[j].Threshold }) + return nil +} + +func quotaStepSpeedFlag(tier string) string { + switch tier { + case "default": + return "standard" + default: + return tier + } +} + func (d *demoFetcher) FetchAccountUsage(context.Context) (codex.AccountUsage, error) { now := time.Now() history := codex.AccountUsage{AccountFingerprint: "demo-account", FetchedAt: now, DailyUsageBuckets: []codex.AccountUsageDay{}} @@ -276,6 +382,7 @@ func defaultDependencies() dependencies { func run(args []string, stdout, stderr io.Writer, deps dependencies) int { flags := flag.NewFlagSet("codexometer", flag.ContinueOnError) flags.SetOutput(stderr) + var quotaSteps quotaStepFlags var ( codexPath = flags.String("codex", "codex", "path to the Codex CLI") refresh = flags.Duration("refresh", time.Minute, "quota refresh interval") @@ -293,6 +400,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.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 { @@ -315,6 +423,10 @@ 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") + return 2 + } if *resetThreshold < 0 || *resetThreshold > 100 { fmt.Fprintln(stderr, "codexometer: --reset-threshold must be between 0 and 100") return 2 @@ -385,13 +497,13 @@ func run(args []string, stdout, stderr io.Writer, deps dependencies) int { if *webMode { // Web mode deliberately gets no benchmark credentials or discovery calls. - client := codex.Client{Binary: *codexPath} + client := codex.Client{Binary: *codexPath, QuotaSteps: quotaSteps} if liveUsage, err := codex.NewLiveUsageReader(""); err == nil { client.LiveUsage = liveUsage } var source web.Source = client if *demo { - source = &demoFetcher{} + source = &demoFetcher{quotaSteps: quotaSteps} } if err := deps.startWeb(source, *refresh, *webPort, stdout, *webControl); err != nil { fmt.Fprintln(stderr, "codexometer:", err) @@ -411,13 +523,13 @@ func run(args []string, stdout, stderr io.Writer, deps dependencies) int { } } digBenchGames = normalizeDigBenchGames(digBenchGames) - client := codex.Client{Binary: *codexPath, BenchmarkAPIKey: benchmarkAPIKey, DigBenchToken: digBenchToken, DigBenchGames: digBenchGames} + client := codex.Client{Binary: *codexPath, BenchmarkAPIKey: benchmarkAPIKey, DigBenchToken: digBenchToken, DigBenchGames: digBenchGames, QuotaSteps: quotaSteps} if liveUsage, err := codex.NewLiveUsageReader(""); err == nil { client.LiveUsage = liveUsage } var fetcher ui.Fetcher = client if *demo { - fetcher = &demoFetcher{} + fetcher = &demoFetcher{quotaSteps: quotaSteps} } if err := deps.startUI(fetcher, *refresh, *inline, *resetThreshold, *resetWarningHours); err != nil { @@ -544,6 +656,21 @@ func startUI(fetcher ui.Fetcher, refresh time.Duration, inline bool, resetThresh model.SetInline(inline) model.SetResetThreshold(resetThreshold) model.SetResetWarningHours(resetWarningHours) - _, err := tea.NewProgram(model).Run() - return err + finalModel, runErr := tea.NewProgram(model).Run() + if final, ok := finalModel.(ui.Model); ok { + final.CancelQuotaWork() + } + if restorer, ok := fetcher.(interface { + CloseQuotaProfiles(context.Context) (int, error) + }); ok { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if _, restoreErr := restorer.CloseQuotaProfiles(ctx); restoreErr != nil { + if runErr != nil { + return fmt.Errorf("%v; restore session settings: %w", runErr, restoreErr) + } + return fmt.Errorf("restore session settings: %w", restoreErr) + } + } + return runErr } diff --git a/main_test.go b/main_test.go index 9e659a4..3d5ed11 100644 --- a/main_test.go +++ b/main_test.go @@ -70,6 +70,33 @@ func TestRunRejectsInvalidFlag(t *testing.T) { } } +func TestQuotaStepFlags(t *testing.T) { + var flags quotaStepFlags + for _, value := range []string{ + "80:gpt-5.6-sol:medium:fast", + "95:gpt-5.6-luna:low:slow", + } { + if err := flags.Set(value); err != nil { + t.Fatal(err) + } + } + if len(flags) != 2 || flags[0].Threshold != 80 || flags[0].ServiceTier != "fast" || + flags[1].Threshold != 95 || flags[1].ServiceTier != "slow" { + t.Fatalf("parsed flags = %#v", flags) + } + if got := flags.String(); got != "80:gpt-5.6-sol:medium:fast,95:gpt-5.6-luna:low:slow" { + t.Fatalf("String() = %q", got) + } + for _, invalid := range []string{ + "0:gpt-5.6-sol:medium", "80::medium", "80:gpt-5.6-sol:extreme", + "80:gpt-5.6-sol:medium:turbo", "80:gpt-5.6-terra:low", + } { + if err := flags.Set(invalid); err == nil { + t.Errorf("accepted invalid step %q", invalid) + } + } +} + func TestRunAuthCheckSuccessAndFailure(t *testing.T) { var stdout, stderr bytes.Buffer deps := dependencies{ From b33afcb0d2bea81bf968806169bd22cefb70595c Mon Sep 17 00:00:00 2001 From: Robert <35533304+merefield@users.noreply.github.com> Date: Fri, 18 Sep 2026 08:01:37 +0100 Subject: [PATCH 2/3] Keep approved quota settings and skip approvals for matching live state --- README.md | 31 +++--- internal/codex/daemon_status_unix.go | 36 +++---- internal/codex/quota_step.go | 31 ++++-- internal/codex/quota_step_test.go | 27 +++++ internal/codex/session_settings_unix.go | 102 +++---------------- internal/codex/session_settings_unix_test.go | 101 +++++++----------- internal/ui/model.go | 14 +-- internal/ui/quota_step_down.go | 53 +++++----- internal/ui/quota_step_down_test.go | 89 ++++++++++++---- main.go | 33 +----- 10 files changed, 239 insertions(+), 278 deletions(-) create mode 100644 internal/codex/quota_step_test.go diff --git a/README.md b/README.md index 6a4a292..64c5dd4 100644 --- a/README.md +++ b/README.md @@ -2092,18 +2092,25 @@ 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. -Acceptance and decline are process-local: restarting Codexometer with the same -options presents eligible gates again, separately for each session and threshold. -Refreshes discover sessions but never reapply a profile or undo manual changes. -At a quota-window/account change and on clean exit, Codexometer attempts to -restore settings it changed, preserving fields subsequently changed manually. -Shutdown drains in-flight updates and rejects later writes before restoration. -Restoration errors are collected across sessions; unloaded sessions, daemon -failures, crashes and forced termination may require manual restoration. Session -updates are not an atomic transaction with concurrent Codex UI changes. -The feature does not edit -`config.toml` or change global Codex defaults and requires a Codex version that -exposes the shared app-server control socket and `thread/settings/update`. +Approved settings remain after Codexometer closes, crashes, or restarts, and +when the quota window changes. Codexometer keeps no model/reasoning/speed +history and never rolls settings back. Change them again in Codex when needed. +Persistence across a Codex/app-server restart is controlled by Codex itself. + +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 +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. + +This feature does not edit `config.toml` or change global Codex defaults. It +requires a Codex version exposing the shared app-server control socket and +experimental `thread/settings/update`. Updates are not atomic with concurrent +settings changes in the Codex UI. ## Experimental browser interface diff --git a/internal/codex/daemon_status_unix.go b/internal/codex/daemon_status_unix.go index 94c7713..54b30ce 100644 --- a/internal/codex/daemon_status_unix.go +++ b/internal/codex/daemon_status_unix.go @@ -25,28 +25,20 @@ 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 - settingsMu sync.Mutex - settingsClosed bool - originalSettings map[string]quotaOwnership - writeMu sync.Mutex -} - -type quotaOwnership struct { - pending bool - original QuotaSession - applied QuotaSession - tierChanged bool + 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 + settingsMu sync.Mutex + settingsClosed bool + writeMu sync.Mutex } type daemonTurnKey struct { diff --git a/internal/codex/quota_step.go b/internal/codex/quota_step.go index f246b70..239a186 100644 --- a/internal/codex/quota_step.go +++ b/internal/codex/quota_step.go @@ -22,8 +22,8 @@ type QuotaStepPolicyProvider interface{ QuotaStepPolicy() []QuotaStep } type SessionSettingsClient interface { QuotaSessions(context.Context) ([]QuotaSession, error) ApplyQuotaProfile(context.Context, []QuotaSession, QuotaStep) (int, error) - RestoreSessionSettings(context.Context) (int, error) - CloseQuotaProfiles(context.Context) (int, error) + ResolveQuotaStep(context.Context, QuotaStep) (QuotaStep, error) + CloseQuotaProfiles() } func (c Client) QuotaStepPolicy() []QuotaStep { return append([]QuotaStep(nil), c.QuotaSteps...) } @@ -46,15 +46,30 @@ func (c Client) ApplyQuotaProfile(ctx context.Context, targets []QuotaSession, s } return 0, errors.New("shared session control unavailable") } -func (c Client) RestoreSessionSettings(ctx context.Context) (int, error) { +func (c Client) ResolveQuotaStep(ctx context.Context, step QuotaStep) (QuotaStep, error) { if p := c.settingsClient(); p != nil { - return p.RestoreSessionSettings(ctx) + return p.ResolveQuotaStep(ctx, step) } - return 0, nil + return step, errors.New("shared session control unavailable") } -func (c Client) CloseQuotaProfiles(ctx context.Context) (int, error) { +func (c Client) CloseQuotaProfiles() { if p := c.settingsClient(); p != nil { - return p.CloseQuotaProfiles(ctx) + p.CloseQuotaProfiles() + } +} + +// MatchesQuotaStep compares current state, not approval history. Resolve an +// advertised speed name to its ID first. Omitted speed imposes no constraint. +func (s QuotaSession) MatchesQuotaStep(step QuotaStep) bool { + if s.Model != step.Model || s.Effort != step.Effort { + return false + } + switch step.ServiceTier { + case "": + return true + case "default": + return s.Tier == nil + default: + return s.Tier != nil && *s.Tier == step.ServiceTier } - return 0, nil } diff --git a/internal/codex/quota_step_test.go b/internal/codex/quota_step_test.go new file mode 100644 index 0000000..c8d0b60 --- /dev/null +++ b/internal/codex/quota_step_test.go @@ -0,0 +1,27 @@ +package codex + +import "testing" + +func TestQuotaTargetMatching(t *testing.T) { + priority, flex := "priority", "flex" + for _, tc := range []struct { + name string + session QuotaSession + step QuotaStep + want bool + }{ + {"exact", QuotaSession{Model: "small", Effort: "medium", Tier: &priority}, QuotaStep{Model: "small", Effort: "medium", ServiceTier: "priority"}, true}, + {"different model", QuotaSession{Model: "large", Effort: "medium", Tier: &priority}, QuotaStep{Model: "small", Effort: "medium", ServiceTier: "priority"}, false}, + {"different reasoning", QuotaSession{Model: "small", Effort: "high", Tier: &priority}, QuotaStep{Model: "small", Effort: "medium", ServiceTier: "priority"}, false}, + {"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}, + {"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) { + if got := tc.session.MatchesQuotaStep(tc.step); got != tc.want { + t.Fatalf("match=%v want=%v", got, tc.want) + } + }) + } +} diff --git a/internal/codex/session_settings_unix.go b/internal/codex/session_settings_unix.go index fee943e..492e20d 100644 --- a/internal/codex/session_settings_unix.go +++ b/internal/codex/session_settings_unix.go @@ -41,7 +41,7 @@ func (p *daemonStatusProvider) readQuotaSession(ctx context.Context, id string) return s, err } if s.Model == "" || s.Effort == "" || len(response.ServiceTier) == 0 { - return s, errors.New("restorable model, effort or service tier unavailable") + return s, errors.New("current model, effort or service tier unavailable") } if err = json.Unmarshal(response.ServiceTier, &s.Tier); err != nil { return s, err @@ -195,16 +195,9 @@ func (p *daemonStatusProvider) ApplyQuotaProfile(ctx context.Context, targets [] if err != nil { return 0, err } - if p.originalSettings == nil { - p.originalSettings = map[string]quotaOwnership{} - } var failures []error updated := 0 for _, target := range targets { - if owner, ok := p.originalSettings[target.ID]; ok && owner.pending { - failures = append(failures, fmt.Errorf("%s: prior update uncertain; restore before another profile", target.ID)) - continue - } if err := ctx.Err(); err != nil { failures = append(failures, err) break @@ -245,102 +238,31 @@ func (p *daemonStatusProvider) ApplyQuotaProfile(ctx context.Context, targets [] updated++ continue } - owner, exists := p.originalSettings[target.ID] - if !exists { - owner.original = current - } else { - // Rebase fields changed manually since our last write. - if current.Model != owner.applied.Model { - owner.original.Model = current.Model - } - if current.Effort != owner.applied.Effort { - owner.original.Effort = current.Effort - } - if !tierEqual(current.Tier, owner.applied.Tier) { - owner.original.Tier = current.Tier - owner.tierChanged = false - } - } - owner.applied = desired - owner.tierChanged = owner.tierChanged || step.ServiceTier != "" - owner.pending = true - // Keep recovery information even when the write outcome is uncertain. - p.originalSettings[target.ID] = owner if err := p.writeQuotaSettings(ctx, desired, params); err != nil { failures = append(failures, fmt.Errorf("%s: %w", target.ID, err)) continue } - owner.pending = false - p.originalSettings[target.ID] = owner updated++ } return updated, errors.Join(failures...) } -func (p *daemonStatusProvider) RestoreSessionSettings(ctx context.Context) (int, error) { - p.settingsMu.Lock() - defer p.settingsMu.Unlock() - return p.restoreQuotaLocked(ctx) -} -func (p *daemonStatusProvider) CloseQuotaProfiles(ctx context.Context) (int, error) { +// Resolve tier names before comparing live settings with the configured target. +func (p *daemonStatusProvider) ResolveQuotaStep(ctx context.Context, step QuotaStep) (QuotaStep, error) { p.settingsMu.Lock() defer p.settingsMu.Unlock() - // Waiting for the lock drains running writes; queued writes must reject. - p.settingsClosed = true - return p.restoreQuotaLocked(ctx) -} -func (p *daemonStatusProvider) restoreQuotaLocked(ctx context.Context) (int, error) { - if len(p.originalSettings) == 0 { - return 0, nil + if p.settingsClosed { + return step, errors.New("quota controller is closed") } if err := p.ensureConnected(ctx); err != nil { - return 0, err - } - loaded, err := p.loadedThreads(ctx) - if err != nil { - return 0, err - } - restored := 0 - var failures []error - for id, owner := range p.originalSettings { - if _, ok := loaded[id]; !ok { - failures = append(failures, fmt.Errorf("%s: unloaded; restore manually", id)) - continue - } - current, err := p.readQuotaSession(ctx, id) - if err != nil { - failures = append(failures, fmt.Errorf("%s: %w", id, err)) - continue - } - desired, params := quotaRestoration(owner, current) - // Compensate an uncertain queued write even if the old settings still read - // back: the acknowledged restore is ordered after our original update. - if !sameQuotaSettings(current, desired) || owner.pending && len(params) > 1 { - if err := p.writeQuotaSettings(ctx, desired, params); err != nil { - failures = append(failures, fmt.Errorf("%s: %w", id, err)) - continue - } - } - delete(p.originalSettings, id) - restored++ + return step, err } - return restored, errors.Join(failures...) + return p.validateQuotaStep(ctx, step) } -func quotaRestoration(owner quotaOwnership, current QuotaSession) (QuotaSession, map[string]any) { - desired := current - params := map[string]any{"threadId": current.ID} - if owner.original.Model != owner.applied.Model && (current.Model == owner.applied.Model || owner.pending && current.Model == owner.original.Model) { - desired.Model = owner.original.Model - params["model"] = desired.Model - } - if owner.original.Effort != owner.applied.Effort && (current.Effort == owner.applied.Effort || owner.pending && current.Effort == owner.original.Effort) { - desired.Effort = owner.original.Effort - params["effort"] = desired.Effort - } - if owner.tierChanged && (tierEqual(current.Tier, owner.applied.Tier) || owner.pending && tierEqual(current.Tier, owner.original.Tier)) { - desired.Tier = owner.original.Tier - params["serviceTier"] = desired.Tier - } - return desired, params +// Drain in-flight work and reject later writes. Closing never changes settings. +func (p *daemonStatusProvider) CloseQuotaProfiles() { + p.settingsMu.Lock() + defer p.settingsMu.Unlock() + p.settingsClosed = true } diff --git a/internal/codex/session_settings_unix_test.go b/internal/codex/session_settings_unix_test.go index bfbebaa..7f6fae8 100644 --- a/internal/codex/session_settings_unix_test.go +++ b/internal/codex/session_settings_unix_test.go @@ -124,45 +124,42 @@ func newQuotaDaemon(t *testing.T) (*daemonStatusProvider, *quotaDaemonFixture) { t.Cleanup(func() { p.disconnect(nil); server.Close() }) return p, fixture } -func TestQuotaSettingsCatalogueDriftAndRestoration(t *testing.T) { +func TestQuotaSettingsPersistAfterCloseAndFreshController(t *testing.T) { p, f := newQuotaDaemon(t) ctx := context.Background() sessions, err := p.QuotaSessions(ctx) - if err != nil || len(sessions) != 2 { - t.Fatalf("scan %v %v", sessions, err) + if err != nil { + t.Fatal(err) } - n, err := p.ApplyQuotaProfile(ctx, sessions, QuotaStep{Model: "small", Effort: "medium", ServiceTier: "slow"}) + step := QuotaStep{Model: "small", Effort: "medium", ServiceTier: "slow"} + n, err := p.ApplyQuotaProfile(ctx, sessions, step) if err != nil || n != 2 { t.Fatalf("apply %d %v", n, err) } - f.mu.Lock() - if *f.sessions["one"].Tier != "priority" { - t.Error("slow did not resolve by catalogue name") - } - s := f.sessions["one"] - s.Model = "manual" - s.Tier = nil - f.sessions["one"] = s - f.fail = "two" - f.mu.Unlock() - n, err = p.RestoreSessionSettings(ctx) - if n != 1 || err == nil { - t.Fatalf("partial restore %d %v", n, err) + p.CloseQuotaProfiles() + fresh := &daemonStatusProvider{socketPath: p.socketPath} + t.Cleanup(func() { fresh.disconnect(nil) }) + resolved, err := fresh.ResolveQuotaStep(ctx, step) + if err != nil || resolved.ServiceTier != "priority" { + t.Fatalf("resolve %#v %v", resolved, err) } - f.mu.Lock() - s = f.sessions["one"] - f.fail = "" - f.mu.Unlock() - if s.Model != "manual" || s.Effort != "high" || s.Tier != nil { - t.Fatalf("manual settings clobbered: %#v", s) + current, err := fresh.QuotaSessions(ctx) + if err != nil || len(current) != 2 { + t.Fatalf("read %#v %v", current, err) } - n, err = p.CloseQuotaProfiles(ctx) - if n != 1 || err != nil { - t.Fatalf("close %d %v", n, err) + for _, session := range current { + if !session.MatchesQuotaStep(resolved) { + t.Fatalf("profile did not persist: %#v", session) + } } - if _, err = p.ApplyQuotaProfile(ctx, sessions, QuotaStep{Model: "small", Effort: "medium"}); err == nil { + if _, err := p.ApplyQuotaProfile(ctx, sessions, step); err == nil { t.Fatal("write after close accepted") } + f.mu.Lock() + defer f.mu.Unlock() + if len(f.writes) != 2 { + t.Fatal("close issued settings writes") + } } func TestQuotaSettingsRejectUnsupportedAndChangedTargets(t *testing.T) { p, f := newQuotaDaemon(t) @@ -205,7 +202,7 @@ func TestQuotaSettingsPreservesUnownedSpeedAndStandardClearsTier(t *testing.T) { s.Tier = &tier f.sessions["one"] = s f.mu.Unlock() - if _, err := p.RestoreSessionSettings(ctx); err != nil { + if _, err := p.ApplyQuotaProfile(ctx, []QuotaSession{s}, QuotaStep{Model: "small", Effort: "medium"}); err != nil { t.Fatal(err) } f.mu.Lock() @@ -226,37 +223,9 @@ func TestQuotaSettingsPreservesUnownedSpeedAndStandardClearsTier(t *testing.T) { } func TestQuotaSettingsNoopCloseDoesNotConnect(t *testing.T) { p := &daemonStatusProvider{socketPath: "/missing/no-socket"} - if n, err := p.CloseQuotaProfiles(context.Background()); n != 0 || err != nil { - t.Fatalf("noop close %d %v", n, err) - } -} - -func TestQuotaRestorationOnlyTouchesOwnedUnmodifiedFields(t *testing.T) { - tier := "flex" - manualTier := "priority" - original := QuotaSession{ID: "one", Model: "large", Effort: "high"} - applied := QuotaSession{ID: "one", Model: "small", Effort: "medium", Tier: &tier} - owner := quotaOwnership{original: original, applied: applied, tierChanged: true} - restored, params := quotaRestoration(owner, applied) - if !sameQuotaSettings(restored, original) || len(params) != 4 { - t.Fatalf("full restore %#v %#v", restored, params) - } - current := applied - current.Model = "manual" - current.Tier = &manualTier - restored, params = quotaRestoration(owner, current) - if restored.Model != "manual" || restored.Effort != "high" || !tierEqual(restored.Tier, &manualTier) || len(params) != 2 { - t.Fatalf("manual restore %#v %#v", restored, params) - } - owner.tierChanged = false - restored, params = quotaRestoration(owner, applied) - if _, ok := params["serviceTier"]; ok || !tierEqual(restored.Tier, applied.Tier) { - t.Fatal("unowned speed overwritten") - } - owner.pending = true - _, params = quotaRestoration(owner, original) - if params["model"] != "large" || params["effort"] != "high" { - t.Fatal("uncertain write has no compensating restore") + p.CloseQuotaProfiles() + if !p.settingsClosed { + t.Fatal("controller not closed") } } func TestQuotaSettingsCloseDrainsInflightWrite(t *testing.T) { @@ -275,7 +244,7 @@ func TestQuotaSettingsCloseDrainsInflightWrite(t *testing.T) { applied <- err }() <-started - go func() { _, err := p.CloseQuotaProfiles(ctx); closed <- err }() + go func() { p.CloseQuotaProfiles(); closed <- nil }() select { case <-closed: t.Fatal("close raced the write") @@ -290,8 +259,8 @@ func TestQuotaSettingsCloseDrainsInflightWrite(t *testing.T) { } f.mu.Lock() defer f.mu.Unlock() - if f.sessions["one"].Model != "large" { - t.Fatal("inflight profile survived close") + if f.sessions["one"].Model != "small" { + t.Fatal("close reverted approved profile") } } func TestQuotaSettingsQueuedAcknowledgementIsNotSuccess(t *testing.T) { @@ -306,12 +275,10 @@ func TestQuotaSettingsQueuedAcknowledgementIsNotSuccess(t *testing.T) { if n != 0 || !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("unverified write %d %v", n, err) } - if _, err := p.CloseQuotaProfiles(context.Background()); err != nil { - t.Fatal(err) - } + p.CloseQuotaProfiles() f.mu.Lock() defer f.mu.Unlock() - if len(f.writes) != 2 || f.writes[1]["model"] != "large" { - t.Fatalf("missing queued compensation: %#v", f.writes) + if len(f.writes) != 1 { + t.Fatalf("unexpected rollback: %#v", f.writes) } } diff --git a/internal/ui/model.go b/internal/ui/model.go index 1c648d9..7bd6a2c 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -517,7 +517,12 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.quotaStepBusy = false // Any newly read inventory invalidates an armed confirmation. m.clearQuotaConfirmation() + if m.quotaStepPending == nil || *m.quotaStepPending != message.step { + m.quota.sessions = nil + return m, m.evaluateQuotaStep(m.snapshot) + } m.quota.sessions = message.sessions + m.quotaStepNotice = fmt.Sprintf("%d session(s) already at target; no approval needed.", message.matched) if message.err != nil { m.quotaStepNotice = "Some sessions unavailable: " + message.err.Error() } @@ -527,15 +532,6 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.quotaStepBusy = false - if message.restoring { - m.quota.restoring = false - m.quotaStepActive = nil - m.quotaStepNotice = "Prior profile restoration finished." - if message.err != nil { - m.quotaStepNotice = "Restoration incomplete; check Codex: " + message.err.Error() - } - return m, nil - } if message.window != m.quotaStepWindow { return m, nil } diff --git a/internal/ui/quota_step_down.go b/internal/ui/quota_step_down.go index edb8706..cb0bcf5 100644 --- a/internal/ui/quota_step_down.go +++ b/internal/ui/quota_step_down.go @@ -22,7 +22,6 @@ type quotaControl struct { confirmStep codex.QuotaStep confirmWindow string confirmAll bool - restoring bool } func (m Model) CancelQuotaWork() { @@ -33,17 +32,18 @@ func (m Model) CancelQuotaWork() { type quotaScanResult struct { revision uint64 + step codex.QuotaStep + matched int sessions []codex.QuotaSession err error } type quotaStepResult struct { - revision uint64 - step codex.QuotaStep - window string - targets []codex.QuotaSession - updated int - err error - restoring bool + revision uint64 + step codex.QuotaStep + window string + targets []codex.QuotaSession + updated int + err error } func quotaStepThreshold(step *codex.QuotaStep) int { @@ -112,21 +112,9 @@ func (m *Model) evaluateQuotaStep(snapshot codex.Snapshot) tea.Cmd { m.quota.sessions = nil m.quota.handled = nil m.quotaStepPending = nil - m.quotaStepWindow = window - m.quotaStepBusy = true - m.quota.restoring = true - m.quotaStepNotice = "Quota window changed: restoring owned settings." - revision := m.quota.revision - return func() tea.Msg { - c, ok := m.fetcher.(codex.SessionSettingsClient) - if !ok { - return quotaStepResult{revision: revision, restoring: true} - } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - n, err := c.RestoreSessionSettings(ctx) - return quotaStepResult{revision: revision, restoring: true, updated: n, err: err} - } + m.quotaStepBusy = false + m.quotaStepActive = nil + m.quotaStepNotice = "Quota window changed. Existing session settings are unchanged." } m.quotaStepWindow = window var candidate *codex.QuotaStep @@ -146,16 +134,30 @@ func (m *Model) evaluateQuotaStep(snapshot codex.Snapshot) tea.Cmd { m.quotaStepBusy = true m.quota.revision++ revision := m.quota.revision + step := *candidate ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) m.quota.cancel = cancel return func() tea.Msg { defer cancel() c, ok := m.fetcher.(codex.SessionSettingsClient) if !ok { - return quotaScanResult{revision: revision, err: fmt.Errorf("shared session control unavailable")} + return quotaScanResult{revision: revision, step: step, err: fmt.Errorf("shared session control unavailable")} + } + resolved, err := c.ResolveQuotaStep(ctx, step) + if err != nil { + return quotaScanResult{revision: revision, step: step, err: err} } sessions, err := c.QuotaSessions(ctx) - return quotaScanResult{revision: revision, sessions: sessions, err: err} + var candidates []codex.QuotaSession + matched := 0 + for _, session := range sessions { + if session.MatchesQuotaStep(resolved) { + matched++ + } else { + candidates = append(candidates, session) + } + } + return quotaScanResult{revision: revision, step: step, sessions: candidates, matched: matched, err: err} } } func (m Model) quotaCandidates() []codex.QuotaSession { @@ -252,6 +254,7 @@ func (m Model) renderQuotaStepNotice(width int) string { if body == "" { body = m.quotaStepLabel() } + body += "\nChanges remain after Codexometer closes." candidates := m.quotaCandidates() if len(candidates) > 0 { s := candidates[m.quota.selected%len(candidates)] diff --git a/internal/ui/quota_step_down_test.go b/internal/ui/quota_step_down_test.go index a24f33d..d701b98 100644 --- a/internal/ui/quota_step_down_test.go +++ b/internal/ui/quota_step_down_test.go @@ -12,10 +12,10 @@ import ( ) type quotaStepTestFetcher struct { - steps []codex.QuotaStep - sessions []codex.QuotaSession - targets []codex.QuotaSession - updates, restores int + steps []codex.QuotaStep + sessions []codex.QuotaSession + targets []codex.QuotaSession + updates int } func (f *quotaStepTestFetcher) Fetch(context.Context) (codex.Snapshot, error) { @@ -28,17 +28,31 @@ func (f *quotaStepTestFetcher) QuotaStepPolicy() []codex.QuotaStep { return f.st func (f *quotaStepTestFetcher) QuotaSessions(context.Context) ([]codex.QuotaSession, error) { return append([]codex.QuotaSession(nil), f.sessions...), nil } -func (f *quotaStepTestFetcher) ApplyQuotaProfile(_ context.Context, targets []codex.QuotaSession, _ codex.QuotaStep) (int, error) { +func (f *quotaStepTestFetcher) ApplyQuotaProfile(_ context.Context, targets []codex.QuotaSession, step codex.QuotaStep) (int, error) { f.updates++ f.targets = targets + for i, session := range f.sessions { + for _, target := range targets { + if session.ID == target.ID { + f.sessions[i].Model = step.Model + f.sessions[i].Effort = step.Effort + if step.ServiceTier == "default" { + f.sessions[i].Tier = nil + } else if step.ServiceTier != "" { + tier := step.ServiceTier + f.sessions[i].Tier = &tier + } + } + } + } return len(targets), nil } -func (f *quotaStepTestFetcher) RestoreSessionSettings(context.Context) (int, error) { - f.restores++ - return 1, nil -} -func (f *quotaStepTestFetcher) CloseQuotaProfiles(ctx context.Context) (int, error) { - return f.RestoreSessionSettings(ctx) +func (f *quotaStepTestFetcher) CloseQuotaProfiles() {} +func (f *quotaStepTestFetcher) ResolveQuotaStep(_ context.Context, step codex.QuotaStep) (codex.QuotaStep, error) { + if step.ServiceTier == "fast" { + step.ServiceTier = "priority" + } + return step, nil } func quotaStepSnapshot(used int, reset int64) codex.Snapshot { s := codex.DemoSnapshot() @@ -147,24 +161,24 @@ func TestQuotaApproveAllBindsReviewedInventory(t *testing.T) { t.Fatal("approved list that cannot fit") } } -func TestQuotaWindowChangeRestoresAndIgnoresLateResults(t *testing.T) { +func TestQuotaWindowChangeLeavesSettingsAndIgnoresLateResults(t *testing.T) { m, f := quotaTestModel(t) m, _ = quotaPress(m, false) m, apply := quotaPress(m, false) old := apply().(quotaStepResult) m.snapshot = quotaStepSnapshot(85, time.Now().Add(2*time.Hour).Unix()) - restore := m.evaluateQuotaStep(m.snapshot) - if restore == nil || !m.quotaStepBusy { - t.Fatal("window change did not restore") + scan := m.evaluateQuotaStep(m.snapshot) + if scan == nil || !m.quotaStepBusy { + t.Fatal("window change did not rescan") } next, _ := m.Update(old) m = next.(Model) if !m.quotaStepBusy { t.Fatal("old result cleared busy state") } - next, _ = m.Update(restore()) + next, _ = m.Update(scan()) m = next.(Model) - if f.restores != 1 || m.quotaStepActive != nil || len(m.quota.handled) != 0 { + if f.updates != 1 || m.quotaStepActive != nil || len(m.quota.handled) != 0 { t.Fatal("window state not reset") } } @@ -209,3 +223,44 @@ func TestQuotaControlsKeepResetVisibleAndClickable(t *testing.T) { } } } + +func TestQuotaRestartUsesCurrentSettingsWithoutApprovalHistory(t *testing.T) { + for _, speed := range []string{"", "default", "fast"} { + t.Run(speed, func(t *testing.T) { + m, f := quotaTestModel(t) + f.steps = f.steps[:1] + f.steps[0].ServiceTier = speed + priority := "priority" + for i := range f.sessions { + f.sessions[i].Model = "small" + f.sessions[i].Effort = "medium" + f.sessions[i].Tier = &priority + if speed == "default" { + f.sessions[i].Tier = nil + } + } + restarted := New(f, time.Minute) + restarted.loading = false + restarted.snapshot = m.snapshot + cmd := restarted.evaluateQuotaStep(restarted.snapshot) + next, _ := restarted.Update(cmd()) + restarted = next.(Model) + if len(restarted.quotaCandidates()) != 0 || len(restarted.quota.handled) != 0 { + t.Fatal("already matching sessions should require no approval history") + } + restarted, cmd = quotaPress(restarted, true) + if cmd != nil || len(restarted.quota.confirm) != 0 || f.updates != 0 { + t.Fatal("matching sessions prompted or wrote settings") + } + // One different session needs approval; the matching session remains excluded. + f.sessions[1].Effort = "high" + cmd = restarted.evaluateQuotaStep(restarted.snapshot) + next, _ = restarted.Update(cmd()) + restarted = next.(Model) + candidates := restarted.quotaCandidates() + if len(candidates) != 1 || candidates[0].ID != "two" { + t.Fatalf("candidates %#v", candidates) + } + }) + } +} diff --git a/main.go b/main.go index fef549b..3d66b9e 100644 --- a/main.go +++ b/main.go @@ -24,7 +24,6 @@ import ( type demoFetcher struct { quotaSteps []codex.QuotaStep - settingsUpdates int approvalDecision string mu sync.Mutex snapshot codex.Snapshot @@ -54,25 +53,12 @@ func (d *demoFetcher) ApplyQuotaProfile(ctx context.Context, targets []codex.Quo } d.mu.Lock() defer d.mu.Unlock() - d.settingsUpdates++ return len(targets), nil } -func (d *demoFetcher) CloseQuotaProfiles(ctx context.Context) (int, error) { - return d.RestoreSessionSettings(ctx) -} - -func (d *demoFetcher) RestoreSessionSettings(ctx context.Context) (int, error) { - if err := ctx.Err(); err != nil { - return 0, err - } - d.mu.Lock() - defer d.mu.Unlock() - if d.settingsUpdates == 0 { - return 0, nil - } - d.settingsUpdates = 0 - return 2, nil +func (d *demoFetcher) CloseQuotaProfiles() {} +func (d *demoFetcher) ResolveQuotaStep(ctx context.Context, step codex.QuotaStep) (codex.QuotaStep, error) { + return step, ctx.Err() } type quotaStepFlags []codex.QuotaStep @@ -660,17 +646,8 @@ func startUI(fetcher ui.Fetcher, refresh time.Duration, inline bool, resetThresh if final, ok := finalModel.(ui.Model); ok { final.CancelQuotaWork() } - if restorer, ok := fetcher.(interface { - CloseQuotaProfiles(context.Context) (int, error) - }); ok { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - if _, restoreErr := restorer.CloseQuotaProfiles(ctx); restoreErr != nil { - if runErr != nil { - return fmt.Errorf("%v; restore session settings: %w", runErr, restoreErr) - } - return fmt.Errorf("restore session settings: %w", restoreErr) - } + if controller, ok := fetcher.(interface{ CloseQuotaProfiles() }); ok { + controller.CloseQuotaProfiles() } return runErr } From 345450561d098323665ef70f749a29f8df333c80 Mon Sep 17 00:00:00 2001 From: Robert <35533304+merefield@users.noreply.github.com> Date: Fri, 18 Sep 2026 08:34:37 +0100 Subject: [PATCH 3/3] Fix quota profile review state, default efforts, and localisation --- internal/codex/session_settings_unix.go | 24 +++-- internal/codex/session_settings_unix_test.go | 23 +++++ internal/i18n/locales/da.json | 24 +++++ internal/i18n/locales/de.json | 24 +++++ internal/i18n/locales/en-GB.json | 24 +++++ internal/i18n/locales/es.json | 24 +++++ internal/i18n/locales/et.json | 24 +++++ internal/i18n/locales/fi.json | 24 +++++ internal/i18n/locales/fr.json | 24 +++++ internal/i18n/locales/it.json | 24 +++++ internal/i18n/locales/ja.json | 24 +++++ internal/i18n/locales/nb.json | 24 +++++ internal/i18n/locales/nl.json | 24 +++++ internal/i18n/locales/pt-BR.json | 24 +++++ internal/i18n/locales/pt-PT.json | 24 +++++ internal/i18n/locales/ru.json | 24 +++++ internal/i18n/locales/sv.json | 24 +++++ internal/i18n/locales/tr.json | 24 +++++ internal/i18n/locales/zh-Hans.json | 24 +++++ internal/ui/localisation_test.go | 1 + internal/ui/model.go | 8 +- internal/ui/quota_step_down.go | 57 ++++++------ internal/ui/quota_step_down_test.go | 96 ++++++++++++++++++++ 23 files changed, 578 insertions(+), 39 deletions(-) diff --git a/internal/codex/session_settings_unix.go b/internal/codex/session_settings_unix.go index 492e20d..b3d601a 100644 --- a/internal/codex/session_settings_unix.go +++ b/internal/codex/session_settings_unix.go @@ -91,9 +91,8 @@ func (p *daemonStatusProvider) validateQuotaStep(ctx context.Context, step Quota for { var response struct { Data []struct { - Model string - SupportedReasoningEfforts []struct{ ReasoningEffort string } - ServiceTiers []struct { + benchmarkModel + ServiceTiers []struct { ID string Name string } @@ -111,12 +110,7 @@ func (p *daemonStatusProvider) validateQuotaStep(ctx context.Context, step Quota if model.Model != step.Model { continue } - effortOK := false - for _, effort := range model.SupportedReasoningEfforts { - if effort.ReasoningEffort == step.Effort { - effortOK = true - } - } + effortOK := quotaEffortSupported(model.benchmarkModel, step.Effort) if !effortOK { return step, errors.New("model does not advertise requested reasoning effort") } @@ -266,3 +260,15 @@ func (p *daemonStatusProvider) CloseQuotaProfiles() { defer p.settingsMu.Unlock() p.settingsClosed = true } + +func quotaEffortSupported(model benchmarkModel, effort string) bool { + if len(model.SupportedReasoningEfforts) == 0 { + return strings.TrimSpace(model.DefaultReasoningEffort) != "" && effort == strings.TrimSpace(model.DefaultReasoningEffort) + } + for _, option := range model.SupportedReasoningEfforts { + if effort == strings.TrimSpace(option.ReasoningEffort) { + return true + } + } + return false +} diff --git a/internal/codex/session_settings_unix_test.go b/internal/codex/session_settings_unix_test.go index 7f6fae8..3ef90ea 100644 --- a/internal/codex/session_settings_unix_test.go +++ b/internal/codex/session_settings_unix_test.go @@ -4,6 +4,7 @@ package codex import ( "context" + "encoding/json" "errors" "github.com/gorilla/websocket" "net" @@ -25,6 +26,28 @@ type quotaDaemonFixture struct { release chan struct{} } +func TestQuotaEffortDefaultFallback(t *testing.T) { + for _, tc := range []struct { + payload, effort string + want bool + }{ + {`{"defaultReasoningEffort":"medium","supportedReasoningEfforts":[]}`, "medium", true}, + {`{"defaultReasoningEffort":"medium"}`, "medium", true}, + {`{"defaultReasoningEffort":"medium"}`, "high", false}, + {`{}`, "medium", false}, + {`{"defaultReasoningEffort":"medium","supportedReasoningEfforts":[{"reasoningEffort":"high"}]}`, "medium", false}, + {`{"defaultReasoningEffort":"medium","supportedReasoningEfforts":[{"reasoningEffort":"high"}]}`, "high", true}, + } { + var model benchmarkModel + if err := json.Unmarshal([]byte(tc.payload), &model); err != nil { + t.Fatal(err) + } + if got := quotaEffortSupported(model, tc.effort); got != tc.want { + t.Fatalf("payload %s effort %s: got %v want %v", tc.payload, tc.effort, got, tc.want) + } + } +} + func newQuotaDaemon(t *testing.T) (*daemonStatusProvider, *quotaDaemonFixture) { t.Helper() fixture := "aDaemonFixture{sessions: map[string]QuotaSession{"one": {ID: "one", Model: "large", Effort: "high"}, "two": {ID: "two", Model: "large", Effort: "high"}}} diff --git a/internal/i18n/locales/da.json b/internal/i18n/locales/da.json index cfdb6b0..4869303 100644 --- a/internal/i18n/locales/da.json +++ b/internal/i18n/locales/da.json @@ -1,4 +1,28 @@ { + "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", "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 5d965e2..c74d199 100644 --- a/internal/i18n/locales/de.json +++ b/internal/i18n/locales/de.json @@ -1,4 +1,28 @@ { + "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", "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 fa1ad06..7f6c329 100644 --- a/internal/i18n/locales/en-GB.json +++ b/internal/i18n/locales/en-GB.json @@ -1,4 +1,28 @@ { + "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", "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 ec478b9..99c77c0 100644 --- a/internal/i18n/locales/es.json +++ b/internal/i18n/locales/es.json @@ -1,4 +1,28 @@ { + "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", "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 df49eb5..f996c0d 100644 --- a/internal/i18n/locales/et.json +++ b/internal/i18n/locales/et.json @@ -1,4 +1,28 @@ { + "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", "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 8d2347b..cc76e90 100644 --- a/internal/i18n/locales/fi.json +++ b/internal/i18n/locales/fi.json @@ -1,4 +1,28 @@ { + "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", "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 402f560..286fe7d 100644 --- a/internal/i18n/locales/fr.json +++ b/internal/i18n/locales/fr.json @@ -1,4 +1,28 @@ { + "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", "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 929bbd9..92746bd 100644 --- a/internal/i18n/locales/it.json +++ b/internal/i18n/locales/it.json @@ -1,4 +1,28 @@ { + "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", "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 ca233ed..a215342 100644 --- a/internal/i18n/locales/ja.json +++ b/internal/i18n/locales/ja.json @@ -1,4 +1,28 @@ { + "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", "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 2b7d026..661fd0d 100644 --- a/internal/i18n/locales/nb.json +++ b/internal/i18n/locales/nb.json @@ -1,4 +1,28 @@ { + "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", "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 3667ea6..5802304 100644 --- a/internal/i18n/locales/nl.json +++ b/internal/i18n/locales/nl.json @@ -1,4 +1,28 @@ { + "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", "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 fba1e3d..10f2132 100644 --- a/internal/i18n/locales/pt-BR.json +++ b/internal/i18n/locales/pt-BR.json @@ -1,4 +1,28 @@ { + "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", "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 13785de..a88d92f 100644 --- a/internal/i18n/locales/pt-PT.json +++ b/internal/i18n/locales/pt-PT.json @@ -1,4 +1,28 @@ { + "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", "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 d6b1af4..20235d2 100644 --- a/internal/i18n/locales/ru.json +++ b/internal/i18n/locales/ru.json @@ -1,4 +1,28 @@ { + "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", "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 20d625e..d52261e 100644 --- a/internal/i18n/locales/sv.json +++ b/internal/i18n/locales/sv.json @@ -1,4 +1,28 @@ { + "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", "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 a157e55..8289c28 100644 --- a/internal/i18n/locales/tr.json +++ b/internal/i18n/locales/tr.json @@ -1,4 +1,28 @@ { + "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", "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 0bc6689..8c9e1ec 100644 --- a/internal/i18n/locales/zh-Hans.json +++ b/internal/i18n/locales/zh-Hans.json @@ -1,4 +1,28 @@ { + "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", "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 1b9ffb0..1fa38ae 100644 --- a/internal/ui/localisation_test.go +++ b/internal/ui/localisation_test.go @@ -67,6 +67,7 @@ func TestLocalisedScreensHelper(t *testing.T) { t.Run("tab_click_surfaces", TestEveryRenderedTabCellIsClickableAcrossWidths) t.Run("reset_warning_surfaces", TestResetWarningClickSurfaces) t.Run("reset_warning_language", TestResetWarningsUseLocale) + t.Run("quota_profile_controls", TestQuotaLocalisedControls) 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 7bd6a2c..84a0023 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -522,9 +522,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 = fmt.Sprintf("%d session(s) already at target; no approval needed.", message.matched) + m.quotaStepNotice = i18n.Format("%d session(s) already at target; no approval needed.", message.matched) if message.err != nil { - m.quotaStepNotice = "Some sessions unavailable: " + message.err.Error() + m.quotaStepNotice = i18n.Format("Session check failed: %s", message.err.Error()) } return m, nil case quotaStepResult: @@ -543,13 +543,13 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.quota.handled[s.ID] = message.step.Threshold } m.quota.sessions = nil // Re-read settings before another threshold can be approved. - m.quotaStepNotice = fmt.Sprintf("Verified %d of %d session updates. No automatic retries.", message.updated, len(message.targets)) + 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 } if message.err != nil { - m.quotaStepNotice += " Check Codex: " + message.err.Error() + m.quotaStepNotice += " " + i18n.Format("Check Codex: %s", message.err.Error()) } return m, nil case tea.KeyPressMsg: diff --git a/internal/ui/quota_step_down.go b/internal/ui/quota_step_down.go index cb0bcf5..8327166 100644 --- a/internal/ui/quota_step_down.go +++ b/internal/ui/quota_step_down.go @@ -7,6 +7,7 @@ import ( "fmt" "github.com/charmbracelet/x/ansi" "github.com/merefield/codexometer/internal/codex" + "github.com/merefield/codexometer/internal/i18n" "reflect" "strings" "time" @@ -57,7 +58,7 @@ func quotaStepProfile(step codex.QuotaStep) string { if step.ServiceTier != "" { result += " / " + step.ServiceTier } else { - result += " / speed unchanged" + result += " / " + i18n.Text("speed unchanged") } return result } @@ -90,6 +91,9 @@ func (m Model) quotaFresh() bool { *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{} } @@ -114,7 +118,7 @@ func (m *Model) evaluateQuotaStep(snapshot codex.Snapshot) tea.Cmd { m.quotaStepPending = nil m.quotaStepBusy = false m.quotaStepActive = nil - m.quotaStepNotice = "Quota window changed. Existing session settings are unchanged." + m.quotaStepNotice = i18n.Text("Quota window changed. Session settings unchanged.") } m.quotaStepWindow = window var candidate *codex.QuotaStep @@ -141,7 +145,7 @@ func (m *Model) evaluateQuotaStep(snapshot codex.Snapshot) tea.Cmd { defer cancel() c, ok := m.fetcher.(codex.SessionSettingsClient) if !ok { - return quotaScanResult{revision: revision, step: step, err: fmt.Errorf("shared session control unavailable")} + return quotaScanResult{revision: revision, step: step, err: fmt.Errorf("%s", i18n.Text("Session controls unavailable."))} } resolved, err := c.ResolveQuotaStep(ctx, step) if err != nil { @@ -190,10 +194,10 @@ func (m Model) pressQuotaChoice(all bool) (tea.Model, tea.Cmd) { m.quota.confirmAll = all m.quota.confirmWindow = m.quotaStepWindow m.quotaStepConfirmUntil = time.Now().Add(10 * time.Second) - m.quotaStepNotice = fmt.Sprintf("Review %d session(s). Repeat the SAME approval action to confirm; Esc cancels. New sessions are not included.", len(targets)) + 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 = "Too many sessions to review together in this pane. Use G to approve individually." + m.quotaStepNotice = i18n.Text("Review sessions individually with G; this pane cannot show all.") } return m, nil } @@ -209,7 +213,7 @@ 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("shared session control unavailable")} + return quotaStepResult{revision: revision, 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} @@ -228,25 +232,22 @@ func (m *Model) declineQuotaStep() { } m.quota.handled[candidates[m.quota.selected%len(candidates)].ID] = m.quotaStepPending.Threshold m.clearQuotaConfirmation() - m.quotaStepNotice = "Session skipped at this gate for this run." + 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 "QUOTA PROFILE: checking / updating…" + return i18n.Text("Checking / updating…") } if len(m.quotaCandidates()) > 0 { - if len(m.quota.confirm) > 0 { - return "[G: CONFIRM ONE] [A: CONFIRM ALL] [N: NEXT] [D: SKIP]" - } - return "[G: REVIEW ONE] [A: REVIEW ALL] [N: NEXT] [D: SKIP]" + return i18n.Text("Review session settings.") } if m.quotaStepActive != nil { - return "QUOTA PROFILE: " + quotaStepProfile(*m.quotaStepActive) + " (approved sessions only)" + return i18n.Format("Approved profile: %s", quotaStepProfile(*m.quotaStepActive)) } - return "QUOTA PROFILE: waiting for threshold / new sessions" + return i18n.Text("Waiting for threshold / new sessions.") } func (m Model) renderQuotaStepNotice(width int) string { c := paletteFor(m.theme) @@ -254,26 +255,26 @@ func (m Model) renderQuotaStepNotice(width int) string { if body == "" { body = m.quotaStepLabel() } - body += "\nChanges remain after Codexometer closes." + body += "\n" + i18n.Text("Changes remain after Codexometer closes.") candidates := m.quotaCandidates() if len(candidates) > 0 { s := candidates[m.quota.selected%len(candidates)] - speed := "unset" + speed := i18n.Text("unset") if s.Tier != nil { speed = *s.Tier } - body += fmt.Sprintf("\nGate %d%% — Session %d/%d: %s\n%s / %s / %s → %s", m.quotaStepPending.Threshold, m.quota.selected%len(candidates)+1, len(candidates), s.ID, s.Model, s.Effort, speed, quotaStepProfile(*m.quotaStepPending)) + 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 := "unset" + speed := i18n.Text("unset") if s.Tier != nil { speed = *s.Tier } body += "\n" + s.ID + ": " + s.Model + " / " + s.Effort + " / " + speed } } - return frame(width, "QUOTA PROFILE", c.label().Render(ansi.Hardwrap(codex.SanitizeSessionContext(body), max(width-4, 1), true)), c.warning, c) + 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 { @@ -316,22 +317,26 @@ func (m Model) quotaActions(width int) []quotaAction { } labels := []string{"[G: REVIEW ONE]", "[A: REVIEW ALL]", "[N: NEXT]", "[D: SKIP]"} if len(m.quota.confirm) > 0 { - labels[0] = "[G: CONFIRM ONE]" - labels[1] = "[A: CONFIRM ALL]" + 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 { - if width < len(label) { + label = i18n.Text(label) + if width < lipgloss.Width(label) { label = "[" + strings.ToUpper(keys[i]) + "]" } - if x > 0 && x+len(label) > width { + if x > 0 && x+lipgloss.Width(label) > width { x = 0 y++ } actions = append(actions, quotaAction{keys[i], label, x, y}) - x += len(label) + 1 + x += lipgloss.Width(label) + 1 } return actions } @@ -342,7 +347,7 @@ func (m Model) quotaActionRows(width int) []string { } rows := make([]string, actions[len(actions)-1].y+1) for _, a := range actions { - rows[a.y] += strings.Repeat(" ", max(a.x-len(rows[a.y]), 0)) + a.label + rows[a.y] += strings.Repeat(" ", max(a.x-lipgloss.Width(rows[a.y]), 0)) + a.label } return rows } @@ -357,7 +362,7 @@ 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+len(a.label) { + if y == g.tabsY+base.extraRows+1+a.y && x >= 2+a.x && x < 2+a.x+lipgloss.Width(a.label) { return a.key } } diff --git a/internal/ui/quota_step_down_test.go b/internal/ui/quota_step_down_test.go index d701b98..80a93a4 100644 --- a/internal/ui/quota_step_down_test.go +++ b/internal/ui/quota_step_down_test.go @@ -6,6 +6,7 @@ import ( "context" "errors" "github.com/merefield/codexometer/internal/codex" + "github.com/merefield/codexometer/internal/i18n" "strings" "testing" "time" @@ -264,3 +265,98 @@ func TestQuotaRestartUsesCurrentSettingsWithoutApprovalHistory(t *testing.T) { }) } } + +func TestQuotaDisarmingClearsReviewNotice(t *testing.T) { + for _, action := range []string{"escape", "timeout", "navigation", "resize", "next"} { + t.Run(action, func(t *testing.T) { + m, _ := quotaTestModel(t) + m, _ = quotaPress(m, false) + if m.quotaStepNotice == "" { + t.Fatal("review notice missing") + } + switch action { + case "escape": + m, _, _ = m.quotaProfileKey("esc") + case "next": + m, _, _ = m.quotaProfileKey("n") + case "timeout": + m.quotaStepConfirmUntil = time.Now().Add(-time.Second) + next, _ := m.Update(secondMsg(time.Now())) + m = next.(Model) + case "navigation": + next, _ := m.pressViewTab(viewUsage) + m = next.(Model) + case "resize": + 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) + } + }) + } + m, _ := quotaTestModel(t) + m.quotaStepNotice = "Result notice" + m.clearQuotaConfirmation() + if m.quotaStepNotice != "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) + } + } +}