detached df967b2

feat(tui): compact status and show Codex weekly allowance (#177)

* feat(tui): compact status and show Codex weekly allowance

* fix(agent): keep Codex usage polling read-only

* fix(agent): commit Codex usage fetcher with replacement agent
commit
df967b23cdfeef1791c2d6bbb0de2163e39c79e7
author
bnema · · 2026-09-05 22:52 UTC
committer
GitHub · · 2026-09-05 22:52 UTC
parent
af6d635
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
15 files · +855 -304
M README.md +12 -0
@@ -121,6 +121,18 @@ Run `zut` and type `/login`. Pick one of two methods:
121121
122122 > **Note on subscription login.** The OAuth client IDs used are the ones published in Anthropic's Claude Code CLI, OpenAI's Codex CLI, Kimi Code CLI, xAI's device flow, and GitHub Copilot's device-code flow. Reusing them from a third-party tool may be against their terms of service and may be revoked at any time. Use it at your own risk; the API-key flow is the safe default.
123123
124+### Compact status bar
125+
126+The interactive status bar combines the model and reasoning level and rounds cache/context percentages and costs:
127+
128+```text
129+gpt-6-astra:medium ↑261k ↓34k R12M C98% $8.52 weekly:69% ctx31%/500k
130+```
131+
132+`↑` / `↓` are session input/output tokens, `R` is cache-read tokens, `C` is the measured cache-hit rate, and `ctx` is context used / model capacity. The provider prefix and `(sub)` tag are omitted from this compact view. For subscription sessions, the dollar amount is an API-equivalent estimate, **not an additional charge**. Detailed session/subagent surfaces retain their existing precision and billing labels; `/session info` shows the provider.
133+
134+For the default `openai-codex` subscription route, `weekly:69%` means **69% of the account's seven-day allowance remains**, across clients—not just this session. zut reads ChatGPT's usage endpoint asynchronously at startup and every five minutes while Codex is selected. Requests time out after ten seconds and are canceled when the session exits or leaves Codex. `weekly:?` means loading, unavailable, stale, or no seven-day window was reported; it never means zero remaining. The meter only reads stored credentials; if the token expires, it stays unavailable until a normal model request or login refreshes it. Custom endpoints and other providers do not receive these usage requests. The meter does not show the separate short-term limit or model-specific allowances and is not a guarantee that the next request will be accepted.
135+
124136 ### Token refresh
125137
126138 OAuth access tokens are short-lived (Anthropic ~8h, OpenAI ~30d; Kimi, xAI, and GitHub Copilot also use refresh/exchange flows). zut refreshes or exchanges them automatically:
@@ -121,6 +121,18 @@ Run `zut` and type `/login`. Pick one of two methods:
121 121
122 > **Note on subscription login.** The OAuth client IDs used are the ones published in Anthropic's Claude Code CLI, OpenAI's Codex CLI, Kimi Code CLI, xAI's device flow, and GitHub Copilot's device-code flow. Reusing them from a third-party tool may be against their terms of service and may be revoked at any time. Use it at your own risk; the API-key flow is the safe default.122 > **Note on subscription login.** The OAuth client IDs used are the ones published in Anthropic's Claude Code CLI, OpenAI's Codex CLI, Kimi Code CLI, xAI's device flow, and GitHub Copilot's device-code flow. Reusing them from a third-party tool may be against their terms of service and may be revoked at any time. Use it at your own risk; the API-key flow is the safe default.
123 123
124+### Compact status bar
125+
126+The interactive status bar combines the model and reasoning level and rounds cache/context percentages and costs:
127+
128+```text
129+gpt-6-astra:medium ↑261k ↓34k R12M C98% $8.52 weekly:69% ctx31%/500k
130+```
131+
132+`↑` / `↓` are session input/output tokens, `R` is cache-read tokens, `C` is the measured cache-hit rate, and `ctx` is context used / model capacity. The provider prefix and `(sub)` tag are omitted from this compact view. For subscription sessions, the dollar amount is an API-equivalent estimate, **not an additional charge**. Detailed session/subagent surfaces retain their existing precision and billing labels; `/session info` shows the provider.
133+
134+For the default `openai-codex` subscription route, `weekly:69%` means **69% of the account's seven-day allowance remains**, across clients—not just this session. zut reads ChatGPT's usage endpoint asynchronously at startup and every five minutes while Codex is selected. Requests time out after ten seconds and are canceled when the session exits or leaves Codex. `weekly:?` means loading, unavailable, stale, or no seven-day window was reported; it never means zero remaining. The meter only reads stored credentials; if the token expires, it stays unavailable until a normal model request or login refreshes it. Custom endpoints and other providers do not receive these usage requests. The meter does not show the separate short-term limit or model-specific allowances and is not a guarantee that the next request will be accepted.
135+
124 ### Token refresh136 ### Token refresh
125 137
126 OAuth access tokens are short-lived (Anthropic ~8h, OpenAI ~30d; Kimi, xAI, and GitHub Copilot also use refresh/exchange flows). zut refreshes or exchanges them automatically:138 OAuth access tokens are short-lived (Anthropic ~8h, OpenAI ~30d; Kimi, xAI, and GitHub Copilot also use refresh/exchange flows). zut refreshes or exchanges them automatically:
M packages/agent/cli.go +4 -4
@@ -1732,7 +1732,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
17321732 resolved.MergeExtensionTools(extToolAdapter)
17331733 installSchedulerTool(resolved.ToolRegistry)
17341734 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1735- return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1735+ return wireAgentExt(resolved.newInteractiveAgent()), resolved.Provider, resolved.Model, nil
17361736 }
17371737
17381738 // Rebuild agent with an explicit provider/model override.
@@ -1757,7 +1757,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
17571757 resolved.MergeExtensionTools(extToolAdapter)
17581758 installSchedulerTool(resolved.ToolRegistry)
17591759 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1760- return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1760+ return wireAgentExt(resolved.newInteractiveAgent()), resolved.Provider, resolved.Model, nil
17611761 }
17621762
17631763 // Rebuild agent for the rescue picker after a recoverable failure.
@@ -1790,11 +1790,11 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
17901790 resolved.MergeExtensionTools(extToolAdapter)
17911791 installSchedulerTool(resolved.ToolRegistry)
17921792 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1793- return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
1793+ return wireAgentExt(resolved.newInteractiveAgent()), resolved.Provider, resolved.Model, nil
17941794 }
17951795
17961796 if r.HasCredential() {
1797- ag = wireAgentExt(r.NewAgent())
1797+ ag = wireAgentExt(r.newInteractiveAgent())
17981798 }
17991799
18001800 // /reload-ext callback: after the manager has respawned every
@@ -1732,7 +1732,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1732 resolved.MergeExtensionTools(extToolAdapter)1732 resolved.MergeExtensionTools(extToolAdapter)
1733 installSchedulerTool(resolved.ToolRegistry)1733 installSchedulerTool(resolved.ToolRegistry)
1734 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)1734 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1735- return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil1735+ return wireAgentExt(resolved.newInteractiveAgent()), resolved.Provider, resolved.Model, nil
1736 }1736 }
1737 1737
1738 // Rebuild agent with an explicit provider/model override.1738 // Rebuild agent with an explicit provider/model override.
@@ -1757,7 +1757,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1757 resolved.MergeExtensionTools(extToolAdapter)1757 resolved.MergeExtensionTools(extToolAdapter)
1758 installSchedulerTool(resolved.ToolRegistry)1758 installSchedulerTool(resolved.ToolRegistry)
1759 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)1759 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1760- return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil1760+ return wireAgentExt(resolved.newInteractiveAgent()), resolved.Provider, resolved.Model, nil
1761 }1761 }
1762 1762
1763 // Rebuild agent for the rescue picker after a recoverable failure.1763 // Rebuild agent for the rescue picker after a recoverable failure.
@@ -1790,11 +1790,11 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1790 resolved.MergeExtensionTools(extToolAdapter)1790 resolved.MergeExtensionTools(extToolAdapter)
1791 installSchedulerTool(resolved.ToolRegistry)1791 installSchedulerTool(resolved.ToolRegistry)
1792 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)1792 prepareResolvedInteractiveRegistry(resolved.ToolRegistry, resolved.WebSearchPolicy)
1793- return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil1793+ return wireAgentExt(resolved.newInteractiveAgent()), resolved.Provider, resolved.Model, nil
1794 }1794 }
1795 1795
1796 if r.HasCredential() {1796 if r.HasCredential() {
1797- ag = wireAgentExt(r.NewAgent())1797+ ag = wireAgentExt(r.newInteractiveAgent())
1798 }1798 }
1799 1799
1800 // /reload-ext callback: after the manager has respawned every1800 // /reload-ext callback: after the manager has respawned every
M packages/agent/cli_session_test.go +37 -0
@@ -893,6 +893,43 @@ func TestPrepareSessionResumeRebuildsForStoredProviderAndModel(t *testing.T) {
893893 }
894894 }
895895
896+func TestCanceledPreparedSessionPreservesLiveAgentClient(t *testing.T) {
897+ t.Setenv("ZUT_HOME", t.TempDir())
898+ old := (Resolved{Provider: "openai-codex", Model: "old-model", AuthMethod: "oauth",
899+ Credential: "synthetic-token", AccountID: "old-account"}).newInteractiveAgent()
900+ old.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "current transcript"}}}})
901+ iv := modes.NewInteractive(modes.InteractiveConfig{Agent: old, Provider: "openai-codex", Model: "old-model"})
902+ oldClient := old.Client
903+ path := syntheticSession(t, "openai-codex", "new-model", provider.Usage{})
904+ loadCtx, cancel := context.WithCancel(context.Background())
905+ defer cancel()
906+ candidate, err := prepareSessionResume(path, old, "openai-codex", "old-model", func(p, m string) (*core.Agent, string, string, error) {
907+ resolved := Resolved{Provider: p, Model: m, AuthMethod: "oauth", Credential: "synthetic-token", AccountID: "new-account"}
908+ return resolved.newInteractiveAgent(), p, m, nil
909+ })
910+ if err != nil {
911+ t.Fatal(err)
912+ }
913+ defer candidate.session.Close()
914+ if !candidate.rebuilt || candidate.agent.Client == oldClient {
915+ t.Fatal("expected a private rebuilt candidate with its own usage client")
916+ }
917+ // Match the loader's cancellation check after preparation and before its
918+ // ApplySessionAgentWithCompactHandoff commit boundary.
919+ cancel()
920+ if loadCtx.Err() == nil {
921+ iv.ApplySessionAgentWithCompactHandoff(candidate.agent, candidate.provider, candidate.model, nil)
922+ }
923+ if iv.Agent() != old || iv.Agent().Client != oldClient || firstMessageText(old.Messages()) != "current transcript" {
924+ t.Fatal("canceled preparation changed the live agent or its usage client")
925+ }
926+ // A later successful load publishes the candidate client with the agent.
927+ iv.ApplySessionAgentWithCompactHandoff(candidate.agent, candidate.provider, candidate.model, nil)
928+ if iv.Agent() != candidate.agent || iv.Agent().Client == oldClient {
929+ t.Fatal("successful commit did not install candidate client")
930+ }
931+}
932+
896933 func TestApplySessionResumeBindsRebuiltAgentToDurableSessionID(t *testing.T) {
897934 path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{})
898935 sess, _, err := core.OpenSession(path)
@@ -893,6 +893,43 @@ func TestPrepareSessionResumeRebuildsForStoredProviderAndModel(t *testing.T) {
893 }893 }
894 }894 }
895 895
896+func TestCanceledPreparedSessionPreservesLiveAgentClient(t *testing.T) {
897+ t.Setenv("ZUT_HOME", t.TempDir())
898+ old := (Resolved{Provider: "openai-codex", Model: "old-model", AuthMethod: "oauth",
899+ Credential: "synthetic-token", AccountID: "old-account"}).newInteractiveAgent()
900+ old.SetMessages([]provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "current transcript"}}}})
901+ iv := modes.NewInteractive(modes.InteractiveConfig{Agent: old, Provider: "openai-codex", Model: "old-model"})
902+ oldClient := old.Client
903+ path := syntheticSession(t, "openai-codex", "new-model", provider.Usage{})
904+ loadCtx, cancel := context.WithCancel(context.Background())
905+ defer cancel()
906+ candidate, err := prepareSessionResume(path, old, "openai-codex", "old-model", func(p, m string) (*core.Agent, string, string, error) {
907+ resolved := Resolved{Provider: p, Model: m, AuthMethod: "oauth", Credential: "synthetic-token", AccountID: "new-account"}
908+ return resolved.newInteractiveAgent(), p, m, nil
909+ })
910+ if err != nil {
911+ t.Fatal(err)
912+ }
913+ defer candidate.session.Close()
914+ if !candidate.rebuilt || candidate.agent.Client == oldClient {
915+ t.Fatal("expected a private rebuilt candidate with its own usage client")
916+ }
917+ // Match the loader's cancellation check after preparation and before its
918+ // ApplySessionAgentWithCompactHandoff commit boundary.
919+ cancel()
920+ if loadCtx.Err() == nil {
921+ iv.ApplySessionAgentWithCompactHandoff(candidate.agent, candidate.provider, candidate.model, nil)
922+ }
923+ if iv.Agent() != old || iv.Agent().Client != oldClient || firstMessageText(old.Messages()) != "current transcript" {
924+ t.Fatal("canceled preparation changed the live agent or its usage client")
925+ }
926+ // A later successful load publishes the candidate client with the agent.
927+ iv.ApplySessionAgentWithCompactHandoff(candidate.agent, candidate.provider, candidate.model, nil)
928+ if iv.Agent() != candidate.agent || iv.Agent().Client == oldClient {
929+ t.Fatal("successful commit did not install candidate client")
930+ }
931+}
932+
896 func TestApplySessionResumeBindsRebuiltAgentToDurableSessionID(t *testing.T) {933 func TestApplySessionResumeBindsRebuiltAgentToDurableSessionID(t *testing.T) {
897 path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{})934 path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{})
898 sess, _, err := core.OpenSession(path)935 sess, _, err := core.OpenSession(path)
A packages/agent/codex_usage.go +40 -0
@@ -0,0 +1,40 @@
1+package agent
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ "github.com/bnema/zut/packages/agent/modes"
9+ "github.com/bnema/zut/packages/core"
10+ "github.com/bnema/zut/packages/provider"
11+)
12+
13+func (r Resolved) newInteractiveAgent() *core.Agent {
14+ ag := r.NewAgent()
15+ modes.BindCodexWeeklyUsageFetcher(ag, r.codexWeeklyUsageFetcher())
16+ return ag
17+}
18+
19+// Build the optional meter from the resolved route, not the TUI's startup auth
20+// metadata. Rebuilds replace it alongside the model client (including rescue).
21+func (r Resolved) codexWeeklyUsageFetcher() func(context.Context) (*provider.CodexWeeklyUsage, error) {
22+ if r.Provider != "openai-codex" || r.AuthMethod != "oauth" || r.AccountID == "" ||
23+ (r.BaseURL != "" && strings.TrimRight(r.BaseURL, "/") != "https://chatgpt.com/backend-api/codex/responses") {
24+ return nil
25+ }
26+ accountID := r.AccountID
27+ return func(ctx context.Context) (*provider.CodexWeeklyUsage, error) {
28+ tok := loadOAuthToken("openai")
29+ if tok == nil || tok.AccountID != accountID {
30+ return nil, fmt.Errorf("codex usage: subscription account changed")
31+ }
32+ // A passive meter must not rotate credentials alongside an inference
33+ // request or write them back after logout. Normal model requests/login
34+ // own refresh; the next poll picks up their newly stored token.
35+ if tok.Expired() {
36+ return nil, fmt.Errorf("codex usage: subscription token expired")
37+ }
38+ return provider.FetchCodexWeeklyUsage(ctx, nil, tok.AccessToken, accountID)
39+ }
40+}
@@ -0,0 +1,40 @@
1+package agent
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ "github.com/bnema/zut/packages/agent/modes"
9+ "github.com/bnema/zut/packages/core"
10+ "github.com/bnema/zut/packages/provider"
11+)
12+
13+func (r Resolved) newInteractiveAgent() *core.Agent {
14+ ag := r.NewAgent()
15+ modes.BindCodexWeeklyUsageFetcher(ag, r.codexWeeklyUsageFetcher())
16+ return ag
17+}
18+
19+// Build the optional meter from the resolved route, not the TUI's startup auth
20+// metadata. Rebuilds replace it alongside the model client (including rescue).
21+func (r Resolved) codexWeeklyUsageFetcher() func(context.Context) (*provider.CodexWeeklyUsage, error) {
22+ if r.Provider != "openai-codex" || r.AuthMethod != "oauth" || r.AccountID == "" ||
23+ (r.BaseURL != "" && strings.TrimRight(r.BaseURL, "/") != "https://chatgpt.com/backend-api/codex/responses") {
24+ return nil
25+ }
26+ accountID := r.AccountID
27+ return func(ctx context.Context) (*provider.CodexWeeklyUsage, error) {
28+ tok := loadOAuthToken("openai")
29+ if tok == nil || tok.AccountID != accountID {
30+ return nil, fmt.Errorf("codex usage: subscription account changed")
31+ }
32+ // A passive meter must not rotate credentials alongside an inference
33+ // request or write them back after logout. Normal model requests/login
34+ // own refresh; the next poll picks up their newly stored token.
35+ if tok.Expired() {
36+ return nil, fmt.Errorf("codex usage: subscription token expired")
37+ }
38+ return provider.FetchCodexWeeklyUsage(ctx, nil, tok.AccessToken, accountID)
39+ }
40+}
A packages/agent/codex_usage_test.go +66 -0
@@ -0,0 +1,66 @@
1+package agent
2+
3+import (
4+ "context"
5+ "net/http"
6+ "net/http/httptest"
7+ "sync/atomic"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/zut/packages/provider/auth"
12+)
13+
14+func TestCodexWeeklyUsageFetcherEligibility(t *testing.T) {
15+ for _, tc := range []struct {
16+ name string
17+ resolved Resolved
18+ want bool
19+ }{
20+ {"subscription", Resolved{Provider: "openai-codex", AuthMethod: "oauth", AccountID: "synthetic"}, true},
21+ {"canonical endpoint", Resolved{Provider: "openai-codex", AuthMethod: "oauth", AccountID: "synthetic", BaseURL: "https://chatgpt.com/backend-api/codex/responses/"}, true},
22+ {"api key", Resolved{Provider: "openai-codex", AuthMethod: "apikey", AccountID: "synthetic"}, false},
23+ {"no account", Resolved{Provider: "openai-codex", AuthMethod: "oauth"}, false},
24+ {"other provider", Resolved{Provider: "openai", AuthMethod: "oauth", AccountID: "synthetic"}, false},
25+ {"custom endpoint", Resolved{Provider: "openai-codex", AuthMethod: "oauth", AccountID: "synthetic", BaseURL: "https://custom.example/responses"}, false},
26+ } {
27+ t.Run(tc.name, func(t *testing.T) {
28+ if got := tc.resolved.codexWeeklyUsageFetcher() != nil; got != tc.want {
29+ t.Fatalf("fetcher available = %t, want %t", got, tc.want)
30+ }
31+ })
32+ }
33+}
34+
35+func TestCodexWeeklyUsageFetcherRejectsMissingChangedAndExpiredCredentials(t *testing.T) {
36+ t.Setenv("ZUT_HOME", t.TempDir())
37+ fetch := (Resolved{Provider: "openai-codex", AuthMethod: "oauth", AccountID: "synthetic-account"}).codexWeeklyUsageFetcher()
38+ if _, err := fetch(context.Background()); err == nil {
39+ t.Fatal("missing credential accepted")
40+ }
41+ store := AuthStoreFor()
42+ if err := store.SetOAuth("openai", auth.OAuthToken{AccessToken: "synthetic-token", AccountID: "different-account", Expiry: time.Now().Add(time.Hour)}); err != nil {
43+ t.Fatal(err)
44+ }
45+ if _, err := fetch(context.Background()); err == nil {
46+ t.Fatal("different account accepted")
47+ }
48+ var refreshCalls atomic.Int32
49+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
50+ refreshCalls.Add(1)
51+ w.WriteHeader(http.StatusUnauthorized)
52+ }))
53+ defer srv.Close()
54+ original := auth.OpenAIOAuth.TokenURL
55+ auth.OpenAIOAuth.TokenURL = srv.URL
56+ t.Cleanup(func() { auth.OpenAIOAuth.TokenURL = original })
57+ if err := store.SetOAuth("openai", auth.OAuthToken{AccessToken: "synthetic-token", RefreshToken: "synthetic-refresh", AccountID: "synthetic-account", Expiry: time.Now().Add(-time.Hour)}); err != nil {
58+ t.Fatal(err)
59+ }
60+ if _, err := fetch(context.Background()); err == nil {
61+ t.Fatal("expired credential accepted")
62+ }
63+ if refreshCalls.Load() != 0 {
64+ t.Fatal("passive usage meter attempted to rotate credentials")
65+ }
66+}
@@ -0,0 +1,66 @@
1+package agent
2+
3+import (
4+ "context"
5+ "net/http"
6+ "net/http/httptest"
7+ "sync/atomic"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/zut/packages/provider/auth"
12+)
13+
14+func TestCodexWeeklyUsageFetcherEligibility(t *testing.T) {
15+ for _, tc := range []struct {
16+ name string
17+ resolved Resolved
18+ want bool
19+ }{
20+ {"subscription", Resolved{Provider: "openai-codex", AuthMethod: "oauth", AccountID: "synthetic"}, true},
21+ {"canonical endpoint", Resolved{Provider: "openai-codex", AuthMethod: "oauth", AccountID: "synthetic", BaseURL: "https://chatgpt.com/backend-api/codex/responses/"}, true},
22+ {"api key", Resolved{Provider: "openai-codex", AuthMethod: "apikey", AccountID: "synthetic"}, false},
23+ {"no account", Resolved{Provider: "openai-codex", AuthMethod: "oauth"}, false},
24+ {"other provider", Resolved{Provider: "openai", AuthMethod: "oauth", AccountID: "synthetic"}, false},
25+ {"custom endpoint", Resolved{Provider: "openai-codex", AuthMethod: "oauth", AccountID: "synthetic", BaseURL: "https://custom.example/responses"}, false},
26+ } {
27+ t.Run(tc.name, func(t *testing.T) {
28+ if got := tc.resolved.codexWeeklyUsageFetcher() != nil; got != tc.want {
29+ t.Fatalf("fetcher available = %t, want %t", got, tc.want)
30+ }
31+ })
32+ }
33+}
34+
35+func TestCodexWeeklyUsageFetcherRejectsMissingChangedAndExpiredCredentials(t *testing.T) {
36+ t.Setenv("ZUT_HOME", t.TempDir())
37+ fetch := (Resolved{Provider: "openai-codex", AuthMethod: "oauth", AccountID: "synthetic-account"}).codexWeeklyUsageFetcher()
38+ if _, err := fetch(context.Background()); err == nil {
39+ t.Fatal("missing credential accepted")
40+ }
41+ store := AuthStoreFor()
42+ if err := store.SetOAuth("openai", auth.OAuthToken{AccessToken: "synthetic-token", AccountID: "different-account", Expiry: time.Now().Add(time.Hour)}); err != nil {
43+ t.Fatal(err)
44+ }
45+ if _, err := fetch(context.Background()); err == nil {
46+ t.Fatal("different account accepted")
47+ }
48+ var refreshCalls atomic.Int32
49+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
50+ refreshCalls.Add(1)
51+ w.WriteHeader(http.StatusUnauthorized)
52+ }))
53+ defer srv.Close()
54+ original := auth.OpenAIOAuth.TokenURL
55+ auth.OpenAIOAuth.TokenURL = srv.URL
56+ t.Cleanup(func() { auth.OpenAIOAuth.TokenURL = original })
57+ if err := store.SetOAuth("openai", auth.OAuthToken{AccessToken: "synthetic-token", RefreshToken: "synthetic-refresh", AccountID: "synthetic-account", Expiry: time.Now().Add(-time.Hour)}); err != nil {
58+ t.Fatal(err)
59+ }
60+ if _, err := fetch(context.Background()); err == nil {
61+ t.Fatal("expired credential accepted")
62+ }
63+ if refreshCalls.Load() != 0 {
64+ t.Fatal("passive usage meter attempted to rotate credentials")
65+ }
66+}
M packages/agent/modes/interactive.go +5 -1
@@ -28,7 +28,7 @@ type InteractiveConfig struct {
2828 Theme tui.Theme
2929 Model string
3030 Provider string
31- AuthMethod string // "apikey" | "oauth" — used to tag cost as (sub) in status bar
31+ AuthMethod string // "apikey" | "oauth" — distinguishes API charges from subscription estimates
3232 BaseURL string
3333 Reasoning string
3434 SystemPrompt string
@@ -706,6 +706,7 @@ type Interactive struct {
706706 helpBlock []string // rendered above the chat when /help was typed
707707 sessionInfoBlocks []sessionInfoBlock
708708 cumUsage provider.Usage
709+ codexUsage codexUsageState
709710 lastCtxInput int // input_tokens of the most recent turn — approximates current context size
710711 busy bool
711712 ctrlCExit bool
@@ -1326,6 +1327,8 @@ func (i *Interactive) Run(ctx context.Context) error {
13261327 // transcript doesn't spin the cpu.
13271328 tick := time.NewTicker(120 * time.Millisecond)
13281329 defer tick.Stop()
1330+ i.refreshCodexUsage(ctx, time.Now())
1331+ defer i.resetCodexUsage()
13291332
13301333 // Redraw throttle: coalesce bursts of invalidate() calls so we paint
13311334 // at most once every redrawMinInterval. Huge tool-result dumps can
@@ -1567,6 +1570,7 @@ func (i *Interactive) Run(ctx context.Context) error {
15671570 requestRedraw()
15681571 case <-tick.C:
15691572 now := time.Now()
1573+ i.refreshCodexUsage(ctx, now)
15701574 if !appearanceDeadline.IsZero() && !now.Before(appearanceDeadline) {
15711575 appearanceParser.SetPendingColors(false)
15721576 appearanceParser.SetPendingScheme(false)
@@ -28,7 +28,7 @@ type InteractiveConfig struct {
28 Theme tui.Theme28 Theme tui.Theme
29 Model string29 Model string
30 Provider string30 Provider string
31- AuthMethod string // "apikey" | "oauth" — used to tag cost as (sub) in status bar31+ AuthMethod string // "apikey" | "oauth" — distinguishes API charges from subscription estimates
32 BaseURL string32 BaseURL string
33 Reasoning string33 Reasoning string
34 SystemPrompt string34 SystemPrompt string
@@ -706,6 +706,7 @@ type Interactive struct {
706 helpBlock []string // rendered above the chat when /help was typed706 helpBlock []string // rendered above the chat when /help was typed
707 sessionInfoBlocks []sessionInfoBlock707 sessionInfoBlocks []sessionInfoBlock
708 cumUsage provider.Usage708 cumUsage provider.Usage
709+ codexUsage codexUsageState
709 lastCtxInput int // input_tokens of the most recent turn — approximates current context size710 lastCtxInput int // input_tokens of the most recent turn — approximates current context size
710 busy bool711 busy bool
711 ctrlCExit bool712 ctrlCExit bool
@@ -1326,6 +1327,8 @@ func (i *Interactive) Run(ctx context.Context) error {
1326 // transcript doesn't spin the cpu.1327 // transcript doesn't spin the cpu.
1327 tick := time.NewTicker(120 * time.Millisecond)1328 tick := time.NewTicker(120 * time.Millisecond)
1328 defer tick.Stop()1329 defer tick.Stop()
1330+ i.refreshCodexUsage(ctx, time.Now())
1331+ defer i.resetCodexUsage()
1329 1332
1330 // Redraw throttle: coalesce bursts of invalidate() calls so we paint1333 // Redraw throttle: coalesce bursts of invalidate() calls so we paint
1331 // at most once every redrawMinInterval. Huge tool-result dumps can1334 // at most once every redrawMinInterval. Huge tool-result dumps can
@@ -1567,6 +1570,7 @@ func (i *Interactive) Run(ctx context.Context) error {
1567 requestRedraw()1570 requestRedraw()
1568 case <-tick.C:1571 case <-tick.C:
1569 now := time.Now()1572 now := time.Now()
1573+ i.refreshCodexUsage(ctx, now)
1570 if !appearanceDeadline.IsZero() && !now.Before(appearanceDeadline) {1574 if !appearanceDeadline.IsZero() && !now.Before(appearanceDeadline) {
1571 appearanceParser.SetPendingColors(false)1575 appearanceParser.SetPendingColors(false)
1572 appearanceParser.SetPendingScheme(false)1576 appearanceParser.SetPendingScheme(false)
A packages/agent/modes/interactive_codex_usage.go +111 -0
@@ -0,0 +1,111 @@
1+package modes
2+
3+import (
4+ "context"
5+ "fmt"
6+ "time"
7+
8+ "github.com/bnema/zut/packages/core"
9+ "github.com/bnema/zut/packages/provider"
10+)
11+
12+const codexUsageInterval = 5 * time.Minute
13+
14+// State is protected by i.mu. Each request has
15+// its own buffered result channel, so canceled/account-switched requests can
16+// finish without blocking or publishing into the new account's display.
17+type codexUsageState struct {
18+ next time.Time
19+ usage *provider.CodexWeeklyUsage
20+ result <-chan *provider.CodexWeeklyUsage
21+ cancel context.CancelFunc
22+}
23+
24+type codexUsageClient struct {
25+ provider.Client
26+ fetch func(context.Context) (*provider.CodexWeeklyUsage, error)
27+}
28+
29+// BindCodexWeeklyUsageFetcher attaches the optional meter to a private candidate
30+// before publication. Preparing or discarding it cannot affect the live meter.
31+// The host supplies nil for API keys, custom endpoints, and other providers.
32+func BindCodexWeeklyUsageFetcher(ag *core.Agent, fetch func(context.Context) (*provider.CodexWeeklyUsage, error)) {
33+ if fetch != nil {
34+ ag.Client = &codexUsageClient{Client: ag.Client, fetch: fetch}
35+ }
36+}
37+
38+func (i *Interactive) codexUsageFetcherLocked() func(context.Context) (*provider.CodexWeeklyUsage, error) {
39+ if i.agent != nil && i.cfg.Provider == "openai-codex" {
40+ if client, ok := i.agent.Client.(*codexUsageClient); ok {
41+ return client.fetch
42+ }
43+ }
44+ return nil
45+}
46+
47+func (i *Interactive) resetCodexUsage() {
48+ i.mu.Lock()
49+ defer i.mu.Unlock()
50+ i.resetCodexUsageLocked()
51+}
52+
53+func (i *Interactive) resetCodexUsageLocked() {
54+ if i.codexUsage.cancel != nil {
55+ i.codexUsage.cancel()
56+ }
57+ i.codexUsage = codexUsageState{}
58+}
59+
60+func (i *Interactive) refreshCodexUsage(ctx context.Context, now time.Time) {
61+ i.mu.Lock()
62+ defer i.mu.Unlock()
63+ fetch := i.codexUsageFetcherLocked()
64+ if fetch == nil || ctx.Err() != nil {
65+ i.resetCodexUsageLocked()
66+ return
67+ }
68+ state := &i.codexUsage
69+ select {
70+ case usage := <-state.result:
71+ state.usage = usage
72+ state.result = nil
73+ state.cancel()
74+ state.cancel = nil
75+ i.invalidate()
76+ default:
77+ }
78+ if state.result != nil || now.Before(state.next) {
79+ return
80+ }
81+ state.next = now.Add(codexUsageInterval)
82+ state.usage = nil
83+ i.invalidate()
84+ requestCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
85+ state.cancel = cancel
86+ result := make(chan *provider.CodexWeeklyUsage, 1)
87+ state.result = result
88+ go func() {
89+ defer cancel()
90+ usage, err := fetch(requestCtx)
91+ if err != nil {
92+ // Background usage is best-effort; never interrupt a conversation
93+ // or expose credentials/account payloads in the status error area.
94+ usage = nil
95+ }
96+ result <- usage
97+ }()
98+}
99+
100+func (i *Interactive) codexWeeklyLabelLocked(now time.Time) string {
101+ if i.codexUsageFetcherLocked() == nil {
102+ return ""
103+ }
104+ usage := i.codexUsage.usage
105+ if usage == nil ||
106+ !now.Before(i.codexUsage.next) ||
107+ (!usage.ResetsAt.IsZero() && !now.Before(usage.ResetsAt)) {
108+ return "weekly:?"
109+ }
110+ return fmt.Sprintf("weekly:%.0f%%", usage.RemainingPercent)
111+}
@@ -0,0 +1,111 @@
1+package modes
2+
3+import (
4+ "context"
5+ "fmt"
6+ "time"
7+
8+ "github.com/bnema/zut/packages/core"
9+ "github.com/bnema/zut/packages/provider"
10+)
11+
12+const codexUsageInterval = 5 * time.Minute
13+
14+// State is protected by i.mu. Each request has
15+// its own buffered result channel, so canceled/account-switched requests can
16+// finish without blocking or publishing into the new account's display.
17+type codexUsageState struct {
18+ next time.Time
19+ usage *provider.CodexWeeklyUsage
20+ result <-chan *provider.CodexWeeklyUsage
21+ cancel context.CancelFunc
22+}
23+
24+type codexUsageClient struct {
25+ provider.Client
26+ fetch func(context.Context) (*provider.CodexWeeklyUsage, error)
27+}
28+
29+// BindCodexWeeklyUsageFetcher attaches the optional meter to a private candidate
30+// before publication. Preparing or discarding it cannot affect the live meter.
31+// The host supplies nil for API keys, custom endpoints, and other providers.
32+func BindCodexWeeklyUsageFetcher(ag *core.Agent, fetch func(context.Context) (*provider.CodexWeeklyUsage, error)) {
33+ if fetch != nil {
34+ ag.Client = &codexUsageClient{Client: ag.Client, fetch: fetch}
35+ }
36+}
37+
38+func (i *Interactive) codexUsageFetcherLocked() func(context.Context) (*provider.CodexWeeklyUsage, error) {
39+ if i.agent != nil && i.cfg.Provider == "openai-codex" {
40+ if client, ok := i.agent.Client.(*codexUsageClient); ok {
41+ return client.fetch
42+ }
43+ }
44+ return nil
45+}
46+
47+func (i *Interactive) resetCodexUsage() {
48+ i.mu.Lock()
49+ defer i.mu.Unlock()
50+ i.resetCodexUsageLocked()
51+}
52+
53+func (i *Interactive) resetCodexUsageLocked() {
54+ if i.codexUsage.cancel != nil {
55+ i.codexUsage.cancel()
56+ }
57+ i.codexUsage = codexUsageState{}
58+}
59+
60+func (i *Interactive) refreshCodexUsage(ctx context.Context, now time.Time) {
61+ i.mu.Lock()
62+ defer i.mu.Unlock()
63+ fetch := i.codexUsageFetcherLocked()
64+ if fetch == nil || ctx.Err() != nil {
65+ i.resetCodexUsageLocked()
66+ return
67+ }
68+ state := &i.codexUsage
69+ select {
70+ case usage := <-state.result:
71+ state.usage = usage
72+ state.result = nil
73+ state.cancel()
74+ state.cancel = nil
75+ i.invalidate()
76+ default:
77+ }
78+ if state.result != nil || now.Before(state.next) {
79+ return
80+ }
81+ state.next = now.Add(codexUsageInterval)
82+ state.usage = nil
83+ i.invalidate()
84+ requestCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
85+ state.cancel = cancel
86+ result := make(chan *provider.CodexWeeklyUsage, 1)
87+ state.result = result
88+ go func() {
89+ defer cancel()
90+ usage, err := fetch(requestCtx)
91+ if err != nil {
92+ // Background usage is best-effort; never interrupt a conversation
93+ // or expose credentials/account payloads in the status error area.
94+ usage = nil
95+ }
96+ result <- usage
97+ }()
98+}
99+
100+func (i *Interactive) codexWeeklyLabelLocked(now time.Time) string {
101+ if i.codexUsageFetcherLocked() == nil {
102+ return ""
103+ }
104+ usage := i.codexUsage.usage
105+ if usage == nil ||
106+ !now.Before(i.codexUsage.next) ||
107+ (!usage.ResetsAt.IsZero() && !now.Before(usage.ResetsAt)) {
108+ return "weekly:?"
109+ }
110+ return fmt.Sprintf("weekly:%.0f%%", usage.RemainingPercent)
111+}
A packages/agent/modes/interactive_codex_usage_test.go +202 -0
@@ -0,0 +1,202 @@
1+package modes
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/zut/packages/core"
11+ "github.com/bnema/zut/packages/provider"
12+)
13+
14+func newCodexUsageTestInteractive(fetch func(context.Context) (*provider.CodexWeeklyUsage, error)) *Interactive {
15+ ag := core.NewAgent(nil, "test", "", nil)
16+ BindCodexWeeklyUsageFetcher(ag, fetch)
17+ return NewInteractive(InteractiveConfig{Provider: "openai-codex", AuthMethod: "oauth", Agent: ag})
18+}
19+
20+// Wait for the worker's channel, then make that result available to the normal
21+// tick path. No sleeps or real five-minute timer are needed.
22+func finishCodexUsageRequest(t *testing.T, i *Interactive, now time.Time) {
23+ t.Helper()
24+ var usage *provider.CodexWeeklyUsage
25+ select {
26+ case usage = <-i.codexUsage.result:
27+ case <-time.After(time.Second):
28+ t.Fatal("usage request did not finish")
29+ }
30+ ready := make(chan *provider.CodexWeeklyUsage, 1)
31+ ready <- usage
32+ i.codexUsage.result = ready
33+ i.refreshCodexUsage(context.Background(), now)
34+}
35+
36+func TestCodexUsagePollIntervalAndFailure(t *testing.T) {
37+ now := time.Unix(2000000000, 0)
38+ var calls atomic.Int32
39+ i := newCodexUsageTestInteractive(func(ctx context.Context) (*provider.CodexWeeklyUsage, error) {
40+ if _, ok := ctx.Deadline(); !ok {
41+ t.Error("usage request has no deadline")
42+ }
43+ if calls.Add(1) == 2 {
44+ return nil, errors.New("synthetic failure")
45+ }
46+ return &provider.CodexWeeklyUsage{RemainingPercent: 69}, nil
47+ })
48+ defer i.resetCodexUsage()
49+ i.refreshCodexUsage(context.Background(), now)
50+ finishCodexUsageRequest(t, i, now)
51+ if got := i.codexWeeklyLabelLocked(now); got != "weekly:69%" {
52+ t.Fatalf("label = %q", got)
53+ }
54+ i.refreshCodexUsage(context.Background(), now.Add(5*time.Minute-time.Nanosecond))
55+ if calls.Load() != 1 {
56+ t.Fatal("polled before five minutes")
57+ }
58+ now = now.Add(5 * time.Minute)
59+ i.refreshCodexUsage(context.Background(), now)
60+ if got := i.codexWeeklyLabelLocked(now); got != "weekly:?" {
61+ t.Fatalf("stale usage during refresh: %q", got)
62+ }
63+ finishCodexUsageRequest(t, i, now)
64+ if calls.Load() != 2 || i.codexWeeklyLabelLocked(now) != "weekly:?" || i.statusErr != "" {
65+ t.Fatal("failed request should clear only the weekly meter")
66+ }
67+ now = now.Add(5 * time.Minute)
68+ i.refreshCodexUsage(context.Background(), now)
69+ finishCodexUsageRequest(t, i, now)
70+ if calls.Load() != 3 || i.codexWeeklyLabelLocked(now) != "weekly:69%" {
71+ t.Fatal("polling did not recover")
72+ }
73+}
74+
75+func TestCodexUsageAsyncSingleFlightAndCancellation(t *testing.T) {
76+ started := make(chan context.Context, 2)
77+ i := newCodexUsageTestInteractive(func(ctx context.Context) (*provider.CodexWeeklyUsage, error) {
78+ started <- ctx
79+ <-ctx.Done()
80+ return &provider.CodexWeeklyUsage{RemainingPercent: 99}, nil
81+ })
82+ defer i.resetCodexUsage()
83+ now := time.Now()
84+ i.refreshCodexUsage(context.Background(), now) // must return while fetch is blocked
85+ var requestCtx context.Context
86+ select {
87+ case requestCtx = <-started:
88+ case <-time.After(time.Second):
89+ t.Fatal("request did not start")
90+ }
91+ oldResult := i.codexUsage.result
92+ i.refreshCodexUsage(context.Background(), now.Add(10*time.Minute))
93+ if i.codexUsage.result != oldResult {
94+ t.Fatal("started overlapping request")
95+ }
96+ candidate := core.NewAgent(nil, "replacement", "", nil)
97+ BindCodexWeeklyUsageFetcher(candidate, i.codexUsageFetcherLocked())
98+ if requestCtx.Err() != nil || i.codexUsage.result != oldResult {
99+ t.Fatal("preparing candidate disturbed live request")
100+ }
101+ i.ApplySessionAgentWithCompactHandoff(candidate, "openai-codex", "replacement", nil)
102+ select {
103+ case <-requestCtx.Done():
104+ case <-time.After(time.Second):
105+ t.Fatal("request not canceled")
106+ }
107+ select {
108+ case <-oldResult:
109+ case <-time.After(time.Second):
110+ t.Fatal("canceled worker did not exit")
111+ }
112+ if i.codexWeeklyLabelLocked(now) != "weekly:?" {
113+ t.Fatal("old account result was published")
114+ }
115+}
116+
117+func TestCodexUsageEligibilityAndProviderSwitch(t *testing.T) {
118+ var calls atomic.Int32
119+ i := newCodexUsageTestInteractive(func(context.Context) (*provider.CodexWeeklyUsage, error) {
120+ calls.Add(1)
121+ return &provider.CodexWeeklyUsage{RemainingPercent: 0}, nil
122+ })
123+ defer i.resetCodexUsage()
124+ now := time.Now()
125+ for _, p := range []string{"openai", "anthropic", "openai-responses"} {
126+ i.cfg.Provider = p
127+ i.refreshCodexUsage(context.Background(), now)
128+ if i.codexWeeklyLabelLocked(now) != "" || i.codexUsage.result != nil {
129+ t.Fatalf("polled for %s", p)
130+ }
131+ }
132+ i.cfg.Provider = "openai-codex"
133+ subscriptionAgent := i.agent
134+ i.ApplySessionAgent(core.NewAgent(nil, "custom", "", nil), "openai-codex", "custom")
135+ i.refreshCodexUsage(context.Background(), now)
136+ if calls.Load() != 0 || i.codexWeeklyLabelLocked(now) != "" {
137+ t.Fatal("polled disabled route")
138+ }
139+ i.ApplySessionAgent(subscriptionAgent, "openai-codex", "test")
140+ i.cfg.AuthMethod = "apikey" // startup metadata from the previous provider
141+ i.refreshCodexUsage(context.Background(), now)
142+ finishCodexUsageRequest(t, i, now)
143+ if i.codexWeeklyLabelLocked(now) != "weekly:0%" {
144+ t.Fatal("switching into Codex did not fetch subscription usage")
145+ }
146+ i.agent = nil // logout
147+ i.refreshCodexUsage(context.Background(), now)
148+ if i.codexWeeklyLabelLocked(now) != "" || i.codexUsage.usage != nil {
149+ t.Fatal("logout retained usage")
150+ }
151+}
152+
153+func TestCodexUsageCandidateIsPrivateUntilSessionCommit(t *testing.T) {
154+ now := time.Now()
155+ i := newCodexUsageTestInteractive(func(context.Context) (*provider.CodexWeeklyUsage, error) {
156+ return &provider.CodexWeeklyUsage{RemainingPercent: 69}, nil
157+ })
158+ defer i.resetCodexUsage()
159+ i.refreshCodexUsage(context.Background(), now)
160+ finishCodexUsageRequest(t, i, now)
161+ previous := i.codexUsage
162+ var candidateCalls atomic.Int32
163+ candidate := core.NewAgent(nil, "replacement", "", nil)
164+ BindCodexWeeklyUsageFetcher(candidate, func(context.Context) (*provider.CodexWeeklyUsage, error) {
165+ candidateCalls.Add(1)
166+ return &provider.CodexWeeklyUsage{RemainingPercent: 42}, nil
167+ })
168+ // A prepared candidate may be discarded by a canceled session load.
169+ i.refreshCodexUsage(context.Background(), now.Add(time.Minute))
170+ if i.codexUsage.usage != previous.usage || i.codexUsage.next != previous.next ||
171+ i.codexWeeklyLabelLocked(now) != "weekly:69%" || candidateCalls.Load() != 0 {
172+ t.Fatal("private candidate changed the live meter or polling schedule")
173+ }
174+ i.ApplySessionAgentWithCompactHandoff(candidate, "openai-codex", "replacement", nil)
175+ if i.codexWeeklyLabelLocked(now) != "weekly:?" || !i.codexUsage.next.IsZero() {
176+ t.Fatal("commit retained the previous account's sample or schedule")
177+ }
178+ i.refreshCodexUsage(context.Background(), now)
179+ finishCodexUsageRequest(t, i, now)
180+ if i.codexWeeklyLabelLocked(now) != "weekly:42%" || candidateCalls.Load() != 1 {
181+ t.Fatal("commit did not activate the candidate meter")
182+ }
183+}
184+
185+func TestCodexUsageMissingAndExpiredWindow(t *testing.T) {
186+ now := time.Now()
187+ i := newCodexUsageTestInteractive(func(context.Context) (*provider.CodexWeeklyUsage, error) { return nil, nil })
188+ defer i.resetCodexUsage()
189+ i.refreshCodexUsage(context.Background(), now)
190+ finishCodexUsageRequest(t, i, now)
191+ if i.codexWeeklyLabelLocked(now) != "weekly:?" {
192+ t.Fatal("missing weekly window presented as zero")
193+ }
194+ i.codexUsage.usage = &provider.CodexWeeklyUsage{RemainingPercent: 69, ResetsAt: now.Add(time.Minute)}
195+ if i.codexWeeklyLabelLocked(now.Add(time.Minute)) != "weekly:?" {
196+ t.Fatal("expired window still visible")
197+ }
198+ i.codexUsage.usage.ResetsAt = time.Time{}
199+ if i.codexWeeklyLabelLocked(now.Add(codexUsageInterval)) != "weekly:?" {
200+ t.Fatal("stale sample still visible")
201+ }
202+}
@@ -0,0 +1,202 @@
1+package modes
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/zut/packages/core"
11+ "github.com/bnema/zut/packages/provider"
12+)
13+
14+func newCodexUsageTestInteractive(fetch func(context.Context) (*provider.CodexWeeklyUsage, error)) *Interactive {
15+ ag := core.NewAgent(nil, "test", "", nil)
16+ BindCodexWeeklyUsageFetcher(ag, fetch)
17+ return NewInteractive(InteractiveConfig{Provider: "openai-codex", AuthMethod: "oauth", Agent: ag})
18+}
19+
20+// Wait for the worker's channel, then make that result available to the normal
21+// tick path. No sleeps or real five-minute timer are needed.
22+func finishCodexUsageRequest(t *testing.T, i *Interactive, now time.Time) {
23+ t.Helper()
24+ var usage *provider.CodexWeeklyUsage
25+ select {
26+ case usage = <-i.codexUsage.result:
27+ case <-time.After(time.Second):
28+ t.Fatal("usage request did not finish")
29+ }
30+ ready := make(chan *provider.CodexWeeklyUsage, 1)
31+ ready <- usage
32+ i.codexUsage.result = ready
33+ i.refreshCodexUsage(context.Background(), now)
34+}
35+
36+func TestCodexUsagePollIntervalAndFailure(t *testing.T) {
37+ now := time.Unix(2000000000, 0)
38+ var calls atomic.Int32
39+ i := newCodexUsageTestInteractive(func(ctx context.Context) (*provider.CodexWeeklyUsage, error) {
40+ if _, ok := ctx.Deadline(); !ok {
41+ t.Error("usage request has no deadline")
42+ }
43+ if calls.Add(1) == 2 {
44+ return nil, errors.New("synthetic failure")
45+ }
46+ return &provider.CodexWeeklyUsage{RemainingPercent: 69}, nil
47+ })
48+ defer i.resetCodexUsage()
49+ i.refreshCodexUsage(context.Background(), now)
50+ finishCodexUsageRequest(t, i, now)
51+ if got := i.codexWeeklyLabelLocked(now); got != "weekly:69%" {
52+ t.Fatalf("label = %q", got)
53+ }
54+ i.refreshCodexUsage(context.Background(), now.Add(5*time.Minute-time.Nanosecond))
55+ if calls.Load() != 1 {
56+ t.Fatal("polled before five minutes")
57+ }
58+ now = now.Add(5 * time.Minute)
59+ i.refreshCodexUsage(context.Background(), now)
60+ if got := i.codexWeeklyLabelLocked(now); got != "weekly:?" {
61+ t.Fatalf("stale usage during refresh: %q", got)
62+ }
63+ finishCodexUsageRequest(t, i, now)
64+ if calls.Load() != 2 || i.codexWeeklyLabelLocked(now) != "weekly:?" || i.statusErr != "" {
65+ t.Fatal("failed request should clear only the weekly meter")
66+ }
67+ now = now.Add(5 * time.Minute)
68+ i.refreshCodexUsage(context.Background(), now)
69+ finishCodexUsageRequest(t, i, now)
70+ if calls.Load() != 3 || i.codexWeeklyLabelLocked(now) != "weekly:69%" {
71+ t.Fatal("polling did not recover")
72+ }
73+}
74+
75+func TestCodexUsageAsyncSingleFlightAndCancellation(t *testing.T) {
76+ started := make(chan context.Context, 2)
77+ i := newCodexUsageTestInteractive(func(ctx context.Context) (*provider.CodexWeeklyUsage, error) {
78+ started <- ctx
79+ <-ctx.Done()
80+ return &provider.CodexWeeklyUsage{RemainingPercent: 99}, nil
81+ })
82+ defer i.resetCodexUsage()
83+ now := time.Now()
84+ i.refreshCodexUsage(context.Background(), now) // must return while fetch is blocked
85+ var requestCtx context.Context
86+ select {
87+ case requestCtx = <-started:
88+ case <-time.After(time.Second):
89+ t.Fatal("request did not start")
90+ }
91+ oldResult := i.codexUsage.result
92+ i.refreshCodexUsage(context.Background(), now.Add(10*time.Minute))
93+ if i.codexUsage.result != oldResult {
94+ t.Fatal("started overlapping request")
95+ }
96+ candidate := core.NewAgent(nil, "replacement", "", nil)
97+ BindCodexWeeklyUsageFetcher(candidate, i.codexUsageFetcherLocked())
98+ if requestCtx.Err() != nil || i.codexUsage.result != oldResult {
99+ t.Fatal("preparing candidate disturbed live request")
100+ }
101+ i.ApplySessionAgentWithCompactHandoff(candidate, "openai-codex", "replacement", nil)
102+ select {
103+ case <-requestCtx.Done():
104+ case <-time.After(time.Second):
105+ t.Fatal("request not canceled")
106+ }
107+ select {
108+ case <-oldResult:
109+ case <-time.After(time.Second):
110+ t.Fatal("canceled worker did not exit")
111+ }
112+ if i.codexWeeklyLabelLocked(now) != "weekly:?" {
113+ t.Fatal("old account result was published")
114+ }
115+}
116+
117+func TestCodexUsageEligibilityAndProviderSwitch(t *testing.T) {
118+ var calls atomic.Int32
119+ i := newCodexUsageTestInteractive(func(context.Context) (*provider.CodexWeeklyUsage, error) {
120+ calls.Add(1)
121+ return &provider.CodexWeeklyUsage{RemainingPercent: 0}, nil
122+ })
123+ defer i.resetCodexUsage()
124+ now := time.Now()
125+ for _, p := range []string{"openai", "anthropic", "openai-responses"} {
126+ i.cfg.Provider = p
127+ i.refreshCodexUsage(context.Background(), now)
128+ if i.codexWeeklyLabelLocked(now) != "" || i.codexUsage.result != nil {
129+ t.Fatalf("polled for %s", p)
130+ }
131+ }
132+ i.cfg.Provider = "openai-codex"
133+ subscriptionAgent := i.agent
134+ i.ApplySessionAgent(core.NewAgent(nil, "custom", "", nil), "openai-codex", "custom")
135+ i.refreshCodexUsage(context.Background(), now)
136+ if calls.Load() != 0 || i.codexWeeklyLabelLocked(now) != "" {
137+ t.Fatal("polled disabled route")
138+ }
139+ i.ApplySessionAgent(subscriptionAgent, "openai-codex", "test")
140+ i.cfg.AuthMethod = "apikey" // startup metadata from the previous provider
141+ i.refreshCodexUsage(context.Background(), now)
142+ finishCodexUsageRequest(t, i, now)
143+ if i.codexWeeklyLabelLocked(now) != "weekly:0%" {
144+ t.Fatal("switching into Codex did not fetch subscription usage")
145+ }
146+ i.agent = nil // logout
147+ i.refreshCodexUsage(context.Background(), now)
148+ if i.codexWeeklyLabelLocked(now) != "" || i.codexUsage.usage != nil {
149+ t.Fatal("logout retained usage")
150+ }
151+}
152+
153+func TestCodexUsageCandidateIsPrivateUntilSessionCommit(t *testing.T) {
154+ now := time.Now()
155+ i := newCodexUsageTestInteractive(func(context.Context) (*provider.CodexWeeklyUsage, error) {
156+ return &provider.CodexWeeklyUsage{RemainingPercent: 69}, nil
157+ })
158+ defer i.resetCodexUsage()
159+ i.refreshCodexUsage(context.Background(), now)
160+ finishCodexUsageRequest(t, i, now)
161+ previous := i.codexUsage
162+ var candidateCalls atomic.Int32
163+ candidate := core.NewAgent(nil, "replacement", "", nil)
164+ BindCodexWeeklyUsageFetcher(candidate, func(context.Context) (*provider.CodexWeeklyUsage, error) {
165+ candidateCalls.Add(1)
166+ return &provider.CodexWeeklyUsage{RemainingPercent: 42}, nil
167+ })
168+ // A prepared candidate may be discarded by a canceled session load.
169+ i.refreshCodexUsage(context.Background(), now.Add(time.Minute))
170+ if i.codexUsage.usage != previous.usage || i.codexUsage.next != previous.next ||
171+ i.codexWeeklyLabelLocked(now) != "weekly:69%" || candidateCalls.Load() != 0 {
172+ t.Fatal("private candidate changed the live meter or polling schedule")
173+ }
174+ i.ApplySessionAgentWithCompactHandoff(candidate, "openai-codex", "replacement", nil)
175+ if i.codexWeeklyLabelLocked(now) != "weekly:?" || !i.codexUsage.next.IsZero() {
176+ t.Fatal("commit retained the previous account's sample or schedule")
177+ }
178+ i.refreshCodexUsage(context.Background(), now)
179+ finishCodexUsageRequest(t, i, now)
180+ if i.codexWeeklyLabelLocked(now) != "weekly:42%" || candidateCalls.Load() != 1 {
181+ t.Fatal("commit did not activate the candidate meter")
182+ }
183+}
184+
185+func TestCodexUsageMissingAndExpiredWindow(t *testing.T) {
186+ now := time.Now()
187+ i := newCodexUsageTestInteractive(func(context.Context) (*provider.CodexWeeklyUsage, error) { return nil, nil })
188+ defer i.resetCodexUsage()
189+ i.refreshCodexUsage(context.Background(), now)
190+ finishCodexUsageRequest(t, i, now)
191+ if i.codexWeeklyLabelLocked(now) != "weekly:?" {
192+ t.Fatal("missing weekly window presented as zero")
193+ }
194+ i.codexUsage.usage = &provider.CodexWeeklyUsage{RemainingPercent: 69, ResetsAt: now.Add(time.Minute)}
195+ if i.codexWeeklyLabelLocked(now.Add(time.Minute)) != "weekly:?" {
196+ t.Fatal("expired window still visible")
197+ }
198+ i.codexUsage.usage.ResetsAt = time.Time{}
199+ if i.codexWeeklyLabelLocked(now.Add(codexUsageInterval)) != "weekly:?" {
200+ t.Fatal("stale sample still visible")
201+ }
202+}
M packages/agent/modes/interactive_events.go +2 -0
@@ -242,6 +242,8 @@ func (i *Interactive) applyAgentPromptConfig(ag *core.Agent, system string, tool
242242 return oldTools, true
243243 }
244244 func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) {
245+ // This runs inside the replacement commit, never during candidate builds.
246+ i.resetCodexUsageLocked()
245247 if ag == nil {
246248 i.setWebSearchAvailable(false)
247249 return
@@ -242,6 +242,8 @@ func (i *Interactive) applyAgentPromptConfig(ag *core.Agent, system string, tool
242 return oldTools, true242 return oldTools, true
243 }243 }
244 func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) {244 func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) {
245+ // This runs inside the replacement commit, never during candidate builds.
246+ i.resetCodexUsageLocked()
245 if ag == nil {247 if ag == nil {
246 i.setWebSearchAvailable(false)248 i.setWebSearchAvailable(false)
247 return249 return
M packages/agent/modes/interactive_rendering.go +1 -0
@@ -682,6 +682,7 @@ func (i *Interactive) redraw() {
682682 GoalStatus: goalStatus,
683683 Usage: i.cumUsage,
684684 Subscription: i.cfg.AuthMethod == "oauth",
685+ WeeklyUsage: i.codexWeeklyLabelLocked(time.Now()),
685686 ContextUsed: i.lastCtxInput,
686687 ContextMax: ctxMax,
687688 AutoCompacting: i.autoCompacting,
@@ -682,6 +682,7 @@ func (i *Interactive) redraw() {
682 GoalStatus: goalStatus,682 GoalStatus: goalStatus,
683 Usage: i.cumUsage,683 Usage: i.cumUsage,
684 Subscription: i.cfg.AuthMethod == "oauth",684 Subscription: i.cfg.AuthMethod == "oauth",
685+ WeeklyUsage: i.codexWeeklyLabelLocked(time.Now()),
685 ContextUsed: i.lastCtxInput,686 ContextUsed: i.lastCtxInput,
686 ContextMax: ctxMax,687 ContextMax: ctxMax,
687 AutoCompacting: i.autoCompacting,688 AutoCompacting: i.autoCompacting,
A packages/provider/codex_usage.go +90 -0
@@ -0,0 +1,90 @@
1+package provider
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "net/http"
9+ "time"
10+)
11+
12+const codexUsageURL = "https://chatgpt.com/backend-api/wham/usage"
13+
14+// CodexWeeklyUsage is the account-wide seven-day allowance, not session usage.
15+// A nil result means the backend did not report a weekly window.
16+type CodexWeeklyUsage struct {
17+ RemainingPercent float64
18+ ResetsAt time.Time
19+}
20+
21+// FetchCodexWeeklyUsage reads ChatGPT subscription usage without making a model
22+// request. Credentials must belong to the selected ChatGPT account. The caller
23+// owns credential refresh and must provide a bounded context.
24+func FetchCodexWeeklyUsage(ctx context.Context, client *http.Client, token, accountID string) (*CodexWeeklyUsage, error) {
25+ return fetchCodexWeeklyUsage(ctx, client, codexUsageURL, token, accountID)
26+}
27+
28+func fetchCodexWeeklyUsage(ctx context.Context, client *http.Client, endpoint, token, accountID string) (*CodexWeeklyUsage, error) {
29+ if token == "" || accountID == "" {
30+ return nil, fmt.Errorf("codex usage: missing subscription credentials")
31+ }
32+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
33+ if err != nil {
34+ return nil, fmt.Errorf("codex usage: invalid endpoint")
35+ }
36+ req.Header.Set("Authorization", "Bearer "+token)
37+ req.Header.Set("ChatGPT-Account-Id", accountID)
38+ req.Header.Set("User-Agent", "codex-cli")
39+ req.Header.Set("Accept", "application/json")
40+ if client == nil {
41+ client = http.DefaultClient
42+ }
43+ // The account header is sensitive too: never forward it on a redirect.
44+ bounded := *client
45+ bounded.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
46+ resp, err := bounded.Do(req)
47+ if err != nil {
48+ return nil, fmt.Errorf("codex usage: request failed")
49+ }
50+ defer resp.Body.Close()
51+ if resp.StatusCode != http.StatusOK {
52+ return nil, fmt.Errorf("codex usage: HTTP %d", resp.StatusCode)
53+ }
54+ type window struct {
55+ UsedPercent *float64 `json:"used_percent"`
56+ Seconds int `json:"limit_window_seconds"`
57+ ResetAt int64 `json:"reset_at"`
58+ }
59+ var payload struct {
60+ RateLimit *struct {
61+ Primary *window `json:"primary_window"`
62+ Secondary *window `json:"secondary_window"`
63+ } `json:"rate_limit"`
64+ }
65+ body, err := io.ReadAll(io.LimitReader(resp.Body, (1<<20)+1))
66+ if err != nil || len(body) > 1<<20 {
67+ return nil, fmt.Errorf("codex usage: unreadable or oversized response")
68+ }
69+ if err := json.Unmarshal(body, &payload); err != nil {
70+ // Do not include response text or JSON errors containing account data.
71+ return nil, fmt.Errorf("codex usage: invalid response")
72+ }
73+ if payload.RateLimit == nil {
74+ return nil, nil
75+ }
76+ for _, w := range []*window{payload.RateLimit.Primary, payload.RateLimit.Secondary} {
77+ if w == nil || w.Seconds != 7*24*60*60 {
78+ continue
79+ }
80+ if w.UsedPercent == nil || *w.UsedPercent < 0 || *w.UsedPercent > 100 {
81+ return nil, fmt.Errorf("codex usage: invalid weekly percentage")
82+ }
83+ usage := &CodexWeeklyUsage{RemainingPercent: 100 - *w.UsedPercent}
84+ if w.ResetAt > 0 {
85+ usage.ResetsAt = time.Unix(w.ResetAt, 0)
86+ }
87+ return usage, nil
88+ }
89+ return nil, nil
90+}
@@ -0,0 +1,90 @@
1+package provider
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "net/http"
9+ "time"
10+)
11+
12+const codexUsageURL = "https://chatgpt.com/backend-api/wham/usage"
13+
14+// CodexWeeklyUsage is the account-wide seven-day allowance, not session usage.
15+// A nil result means the backend did not report a weekly window.
16+type CodexWeeklyUsage struct {
17+ RemainingPercent float64
18+ ResetsAt time.Time
19+}
20+
21+// FetchCodexWeeklyUsage reads ChatGPT subscription usage without making a model
22+// request. Credentials must belong to the selected ChatGPT account. The caller
23+// owns credential refresh and must provide a bounded context.
24+func FetchCodexWeeklyUsage(ctx context.Context, client *http.Client, token, accountID string) (*CodexWeeklyUsage, error) {
25+ return fetchCodexWeeklyUsage(ctx, client, codexUsageURL, token, accountID)
26+}
27+
28+func fetchCodexWeeklyUsage(ctx context.Context, client *http.Client, endpoint, token, accountID string) (*CodexWeeklyUsage, error) {
29+ if token == "" || accountID == "" {
30+ return nil, fmt.Errorf("codex usage: missing subscription credentials")
31+ }
32+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
33+ if err != nil {
34+ return nil, fmt.Errorf("codex usage: invalid endpoint")
35+ }
36+ req.Header.Set("Authorization", "Bearer "+token)
37+ req.Header.Set("ChatGPT-Account-Id", accountID)
38+ req.Header.Set("User-Agent", "codex-cli")
39+ req.Header.Set("Accept", "application/json")
40+ if client == nil {
41+ client = http.DefaultClient
42+ }
43+ // The account header is sensitive too: never forward it on a redirect.
44+ bounded := *client
45+ bounded.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
46+ resp, err := bounded.Do(req)
47+ if err != nil {
48+ return nil, fmt.Errorf("codex usage: request failed")
49+ }
50+ defer resp.Body.Close()
51+ if resp.StatusCode != http.StatusOK {
52+ return nil, fmt.Errorf("codex usage: HTTP %d", resp.StatusCode)
53+ }
54+ type window struct {
55+ UsedPercent *float64 `json:"used_percent"`
56+ Seconds int `json:"limit_window_seconds"`
57+ ResetAt int64 `json:"reset_at"`
58+ }
59+ var payload struct {
60+ RateLimit *struct {
61+ Primary *window `json:"primary_window"`
62+ Secondary *window `json:"secondary_window"`
63+ } `json:"rate_limit"`
64+ }
65+ body, err := io.ReadAll(io.LimitReader(resp.Body, (1<<20)+1))
66+ if err != nil || len(body) > 1<<20 {
67+ return nil, fmt.Errorf("codex usage: unreadable or oversized response")
68+ }
69+ if err := json.Unmarshal(body, &payload); err != nil {
70+ // Do not include response text or JSON errors containing account data.
71+ return nil, fmt.Errorf("codex usage: invalid response")
72+ }
73+ if payload.RateLimit == nil {
74+ return nil, nil
75+ }
76+ for _, w := range []*window{payload.RateLimit.Primary, payload.RateLimit.Secondary} {
77+ if w == nil || w.Seconds != 7*24*60*60 {
78+ continue
79+ }
80+ if w.UsedPercent == nil || *w.UsedPercent < 0 || *w.UsedPercent > 100 {
81+ return nil, fmt.Errorf("codex usage: invalid weekly percentage")
82+ }
83+ usage := &CodexWeeklyUsage{RemainingPercent: 100 - *w.UsedPercent}
84+ if w.ResetAt > 0 {
85+ usage.ResetsAt = time.Unix(w.ResetAt, 0)
86+ }
87+ return usage, nil
88+ }
89+ return nil, nil
90+}
A packages/provider/codex_usage_test.go +125 -0
@@ -0,0 +1,125 @@
1+package provider
2+
3+import (
4+ "context"
5+ "fmt"
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "sync/atomic"
10+ "testing"
11+ "time"
12+)
13+
14+func TestFetchCodexWeeklyUsage(t *testing.T) {
15+ for _, tc := range []struct {
16+ name, body string
17+ status int
18+ want *float64
19+ wantErr bool
20+ }{
21+ {"secondary weekly", `{"rate_limit":{"primary_window":{"used_percent":12,"limit_window_seconds":18000},"secondary_window":{"used_percent":31,"limit_window_seconds":604800,"reset_at":2000000000}}}`, 200, usagePercent(69), false},
22+ {"primary weekly", `{"rate_limit":{"primary_window":{"used_percent":0,"limit_window_seconds":604800}}}`, 200, usagePercent(100), false},
23+ {"exhausted", `{"rate_limit":{"secondary_window":{"used_percent":100,"limit_window_seconds":604800}}}`, 200, usagePercent(0), false},
24+ {"fractional", `{"rate_limit":{"secondary_window":{"used_percent":30.5,"limit_window_seconds":604800}}}`, 200, usagePercent(69.5), false},
25+ {"absent", `{}`, 200, nil, false},
26+ {"null", `{"rate_limit":null}`, 200, nil, false},
27+ {"non weekly secondary", `{"rate_limit":{"secondary_window":{"used_percent":31,"limit_window_seconds":3600}}}`, 200, nil, false},
28+ {"missing percent", `{"rate_limit":{"secondary_window":{"limit_window_seconds":604800}}}`, 200, nil, true},
29+ {"negative", `{"rate_limit":{"secondary_window":{"used_percent":-1,"limit_window_seconds":604800}}}`, 200, nil, true},
30+ {"over limit", `{"rate_limit":{"secondary_window":{"used_percent":101,"limit_window_seconds":604800}}}`, 200, nil, true},
31+ {"invalid json", `private-response`, 200, nil, true},
32+ {"oversized", strings.Repeat(" ", 1<<20) + `{}`, 200, nil, true},
33+ {"unauthorized", `private-response`, 401, nil, true},
34+ {"rate limited", `private-response`, 429, nil, true},
35+ {"server error", `private-response`, 503, nil, true},
36+ } {
37+ t.Run(tc.name, func(t *testing.T) {
38+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
39+ if r.Method != http.MethodGet || r.URL.Path != "/backend-api/wham/usage" {
40+ t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
41+ }
42+ if r.Header.Get("Authorization") != "Bearer synthetic-token" || r.Header.Get("ChatGPT-Account-Id") != "synthetic-account" || r.Header.Get("User-Agent") != "codex-cli" {
43+ t.Error("missing subscription headers")
44+ }
45+ w.WriteHeader(tc.status)
46+ fmt.Fprint(w, tc.body)
47+ }))
48+ defer srv.Close()
49+ got, err := fetchCodexWeeklyUsage(context.Background(), srv.Client(), srv.URL+"/backend-api/wham/usage", "synthetic-token", "synthetic-account")
50+ if (err != nil) != tc.wantErr {
51+ t.Fatalf("error = %v, wantErr %t", err, tc.wantErr)
52+ }
53+ if err != nil && (strings.Contains(err.Error(), "synthetic") || strings.Contains(err.Error(), "private-response")) {
54+ t.Fatal("error leaked sensitive data")
55+ }
56+ if tc.want == nil {
57+ if got != nil {
58+ t.Fatalf("unexpected usage: %+v", got)
59+ }
60+ return
61+ }
62+ if got == nil || got.RemainingPercent != *tc.want {
63+ t.Fatalf("usage = %+v, want %v", got, *tc.want)
64+ }
65+ if tc.name == "secondary weekly" && !got.ResetsAt.Equal(time.Unix(2000000000, 0)) {
66+ t.Fatalf("reset = %v", got.ResetsAt)
67+ }
68+ })
69+ }
70+}
71+
72+func usagePercent(n float64) *float64 { return &n }
73+
74+func TestFetchCodexWeeklyUsageCancellation(t *testing.T) {
75+ started := make(chan struct{})
76+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
77+ close(started)
78+ <-r.Context().Done()
79+ }))
80+ defer srv.Close()
81+ ctx, cancel := context.WithCancel(context.Background())
82+ defer cancel()
83+ done := make(chan error, 1)
84+ go func() {
85+ _, err := fetchCodexWeeklyUsage(ctx, srv.Client(), srv.URL, "synthetic-token", "synthetic-account")
86+ done <- err
87+ }()
88+ select {
89+ case <-started:
90+ case <-time.After(time.Second):
91+ t.Fatal("request did not start")
92+ }
93+ cancel()
94+ select {
95+ case err := <-done:
96+ if err == nil {
97+ t.Fatal("expected cancellation error")
98+ }
99+ case <-time.After(time.Second):
100+ t.Fatal("request ignored cancellation")
101+ }
102+}
103+
104+func TestFetchCodexWeeklyUsageRejectsRedirectsAndMissingCredentials(t *testing.T) {
105+ var calls atomic.Int32
106+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
107+ calls.Add(1)
108+ http.Redirect(w, r, "/private-target", http.StatusFound)
109+ }))
110+ defer srv.Close()
111+ for _, creds := range [][2]string{{"", "account"}, {"token", ""}} {
112+ if _, err := fetchCodexWeeklyUsage(context.Background(), srv.Client(), srv.URL, creds[0], creds[1]); err == nil {
113+ t.Fatal("missing credentials accepted")
114+ }
115+ }
116+ if calls.Load() != 0 {
117+ t.Fatal("missing credentials made a request")
118+ }
119+ if _, err := fetchCodexWeeklyUsage(context.Background(), srv.Client(), srv.URL, "token", "account"); err == nil {
120+ t.Fatal("redirect accepted")
121+ }
122+ if calls.Load() != 1 {
123+ t.Fatalf("redirect followed: %d requests", calls.Load())
124+ }
125+}
@@ -0,0 +1,125 @@
1+package provider
2+
3+import (
4+ "context"
5+ "fmt"
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "sync/atomic"
10+ "testing"
11+ "time"
12+)
13+
14+func TestFetchCodexWeeklyUsage(t *testing.T) {
15+ for _, tc := range []struct {
16+ name, body string
17+ status int
18+ want *float64
19+ wantErr bool
20+ }{
21+ {"secondary weekly", `{"rate_limit":{"primary_window":{"used_percent":12,"limit_window_seconds":18000},"secondary_window":{"used_percent":31,"limit_window_seconds":604800,"reset_at":2000000000}}}`, 200, usagePercent(69), false},
22+ {"primary weekly", `{"rate_limit":{"primary_window":{"used_percent":0,"limit_window_seconds":604800}}}`, 200, usagePercent(100), false},
23+ {"exhausted", `{"rate_limit":{"secondary_window":{"used_percent":100,"limit_window_seconds":604800}}}`, 200, usagePercent(0), false},
24+ {"fractional", `{"rate_limit":{"secondary_window":{"used_percent":30.5,"limit_window_seconds":604800}}}`, 200, usagePercent(69.5), false},
25+ {"absent", `{}`, 200, nil, false},
26+ {"null", `{"rate_limit":null}`, 200, nil, false},
27+ {"non weekly secondary", `{"rate_limit":{"secondary_window":{"used_percent":31,"limit_window_seconds":3600}}}`, 200, nil, false},
28+ {"missing percent", `{"rate_limit":{"secondary_window":{"limit_window_seconds":604800}}}`, 200, nil, true},
29+ {"negative", `{"rate_limit":{"secondary_window":{"used_percent":-1,"limit_window_seconds":604800}}}`, 200, nil, true},
30+ {"over limit", `{"rate_limit":{"secondary_window":{"used_percent":101,"limit_window_seconds":604800}}}`, 200, nil, true},
31+ {"invalid json", `private-response`, 200, nil, true},
32+ {"oversized", strings.Repeat(" ", 1<<20) + `{}`, 200, nil, true},
33+ {"unauthorized", `private-response`, 401, nil, true},
34+ {"rate limited", `private-response`, 429, nil, true},
35+ {"server error", `private-response`, 503, nil, true},
36+ } {
37+ t.Run(tc.name, func(t *testing.T) {
38+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
39+ if r.Method != http.MethodGet || r.URL.Path != "/backend-api/wham/usage" {
40+ t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
41+ }
42+ if r.Header.Get("Authorization") != "Bearer synthetic-token" || r.Header.Get("ChatGPT-Account-Id") != "synthetic-account" || r.Header.Get("User-Agent") != "codex-cli" {
43+ t.Error("missing subscription headers")
44+ }
45+ w.WriteHeader(tc.status)
46+ fmt.Fprint(w, tc.body)
47+ }))
48+ defer srv.Close()
49+ got, err := fetchCodexWeeklyUsage(context.Background(), srv.Client(), srv.URL+"/backend-api/wham/usage", "synthetic-token", "synthetic-account")
50+ if (err != nil) != tc.wantErr {
51+ t.Fatalf("error = %v, wantErr %t", err, tc.wantErr)
52+ }
53+ if err != nil && (strings.Contains(err.Error(), "synthetic") || strings.Contains(err.Error(), "private-response")) {
54+ t.Fatal("error leaked sensitive data")
55+ }
56+ if tc.want == nil {
57+ if got != nil {
58+ t.Fatalf("unexpected usage: %+v", got)
59+ }
60+ return
61+ }
62+ if got == nil || got.RemainingPercent != *tc.want {
63+ t.Fatalf("usage = %+v, want %v", got, *tc.want)
64+ }
65+ if tc.name == "secondary weekly" && !got.ResetsAt.Equal(time.Unix(2000000000, 0)) {
66+ t.Fatalf("reset = %v", got.ResetsAt)
67+ }
68+ })
69+ }
70+}
71+
72+func usagePercent(n float64) *float64 { return &n }
73+
74+func TestFetchCodexWeeklyUsageCancellation(t *testing.T) {
75+ started := make(chan struct{})
76+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
77+ close(started)
78+ <-r.Context().Done()
79+ }))
80+ defer srv.Close()
81+ ctx, cancel := context.WithCancel(context.Background())
82+ defer cancel()
83+ done := make(chan error, 1)
84+ go func() {
85+ _, err := fetchCodexWeeklyUsage(ctx, srv.Client(), srv.URL, "synthetic-token", "synthetic-account")
86+ done <- err
87+ }()
88+ select {
89+ case <-started:
90+ case <-time.After(time.Second):
91+ t.Fatal("request did not start")
92+ }
93+ cancel()
94+ select {
95+ case err := <-done:
96+ if err == nil {
97+ t.Fatal("expected cancellation error")
98+ }
99+ case <-time.After(time.Second):
100+ t.Fatal("request ignored cancellation")
101+ }
102+}
103+
104+func TestFetchCodexWeeklyUsageRejectsRedirectsAndMissingCredentials(t *testing.T) {
105+ var calls atomic.Int32
106+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
107+ calls.Add(1)
108+ http.Redirect(w, r, "/private-target", http.StatusFound)
109+ }))
110+ defer srv.Close()
111+ for _, creds := range [][2]string{{"", "account"}, {"token", ""}} {
112+ if _, err := fetchCodexWeeklyUsage(context.Background(), srv.Client(), srv.URL, creds[0], creds[1]); err == nil {
113+ t.Fatal("missing credentials accepted")
114+ }
115+ }
116+ if calls.Load() != 0 {
117+ t.Fatal("missing credentials made a request")
118+ }
119+ if _, err := fetchCodexWeeklyUsage(context.Background(), srv.Client(), srv.URL, "token", "account"); err == nil {
120+ t.Fatal("redirect accepted")
121+ }
122+ if calls.Load() != 1 {
123+ t.Fatalf("redirect followed: %d requests", calls.Load())
124+ }
125+}
M packages/tui/statusbar_test.go +101 -232
@@ -1,20 +1,42 @@
11 package tui
22
33 import (
4+ "fmt"
45 "strings"
56 "testing"
67
78 "github.com/bnema/zut/packages/provider"
89 )
910
11+func TestStatusBarCompact(t *testing.T) {
12+ lines := StatusBar(StatusBarParams{
13+ Theme: Dark, Provider: "openai-codex", Model: "gpt-6-astra", Reasoning: "medium", Cols: 120,
14+ Usage: provider.Usage{InputTokens: 261_000, OutputTokens: 34_000, CacheReadTokens: 12_000_000,
15+ CacheMeasuredPromptTokens: 1000, CacheMeasuredReadTokens: 979, CostUSD: 8.522},
16+ Subscription: true, ContextUsed: 155_000, ContextMax: 500_000, WeeklyUsage: "weekly:69%",
17+ CWD: "/tmp/x",
18+ })
19+ if len(lines) != 2 {
20+ t.Fatalf("want stats + cwd, got %q", lines)
21+ }
22+ want := " gpt-6-astra:medium ↑261k ↓34k R12M C98% $8.52 weekly:69% ctx31%/500k"
23+ if got := stripANSI(lines[0]); got != want {
24+ t.Fatalf("status = %q, want %q", got, want)
25+ }
26+ if got := stripANSI(lines[1]); got != " /tmp/x" {
27+ t.Fatalf("cwd = %q", got)
28+ }
29+}
30+
1031 func TestStatusBarShowsCacheHitRatio(t *testing.T) {
1132 lines := StatusBar(StatusBarParams{
1233 Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: 200,
13- Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000},
34+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000,
35+ CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000},
1436 ContextUsed: 45_152, ContextMax: 272_000,
1537 })
1638 plain := stripANSI(strings.Join(lines, "\n"))
17- if !strings.Contains(plain, "↑84k ↓1.5k R123k/ C59.4%") {
39+ if !strings.Contains(plain, "↑84k ↓1.5k R123k C59%") {
1840 t.Fatalf("status bar = %q, want compact cache hit ratio", plain)
1941 }
2042 }
@@ -30,273 +52,120 @@ func TestStatusBarOmitsCacheRatioWhenCacheDetailsAreUnavailable(t *testing.T) {
3052 }
3153 }
3254
33-func TestStatusBarWrapsCacheStatsWithinNarrowWidth(t *testing.T) {
34- const cols = 30
35- lines := StatusBar(StatusBarParams{
36- Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: cols,
37- Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheWriteTokens: 2_000, CacheMeasuredPromptTokens: 209_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
38- ContextUsed: 209_000, ContextMax: 272_000,
39- })
40- plain := stripANSI(strings.Join(lines, "\n"))
41- if !strings.Contains(plain, "C58.9%") {
42- t.Fatalf("narrow status bar = %q, want cache hit ratio", plain)
43- }
44- for _, line := range lines {
45- if width := visibleWidth(line); width > cols {
46- t.Fatalf("narrow status line width = %d, want <= %d: %q", width, cols, stripANSI(line))
55+func TestStatusBarNarrow(t *testing.T) {
56+ for _, cols := range []int{24, 30, 32, 40, 64} {
57+ for _, busy := range []string{"", "⠋ Working"} {
58+ t.Run(fmt.Sprintf("%d/busy=%t", cols, busy != ""), func(t *testing.T) {
59+ lines := StatusBar(StatusBarParams{
60+ Theme: Dark, Provider: "openai-codex", Model: "gpt-5.5", Reasoning: "minimum", Cols: cols,
61+ BusyPrefix: busy, CWD: "/tmp/x", FastMode: true, WeeklyUsage: "weekly:69%",
62+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000,
63+ CacheWriteTokens: 2_000, CacheMeasuredPromptTokens: 209_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
64+ ContextUsed: 209_000, ContextMax: 272_000,
65+ })
66+ plain := stripANSI(strings.Join(lines, "\n"))
67+ for _, want := range []string{"gpt-5.5:minimal", "fast mode", "↑84k", "↓1.5k", "R123k", "C59%", "W2.0k", "$0.53", "weekly:69%", "ctx77%/272k", "/tmp/x"} {
68+ if !strings.Contains(plain, want) {
69+ t.Fatalf("missing %q in %q", want, plain)
70+ }
71+ }
72+ for _, line := range lines {
73+ if width := visibleWidth(line); width > cols {
74+ t.Fatalf("line width = %d, want <= %d: %q", width, cols, stripANSI(line))
75+ }
76+ }
77+ if busy != "" && stripANSI(lines[0]) != " "+busy {
78+ t.Fatalf("busy prefix should stay on its own row: %q", lines)
79+ }
80+ })
4781 }
4882 }
4983 }
5084
51-func TestStatusBarShowsActiveGoal(t *testing.T) {
52- params := StatusBarParams{
53- Theme: Dark,
54- Provider: "openai",
55- Model: "gpt-test",
56- GoalStatus: "active",
57- CWD: "/tmp/project",
58- Cols: 100,
59- }
60- lines := StatusBar(params)
61- if len(lines) == 0 || !strings.Contains(lines[0], "goal:active") {
62- t.Fatalf("status bar = %q, want active goal", lines)
85+func TestStatusBarVeryNarrowModel(t *testing.T) {
86+ for _, busy := range []string{"", "⠋"} {
87+ lines := StatusBar(StatusBarParams{Theme: Dark, Model: "模型-with-long-name", Reasoning: "max", BusyPrefix: busy, Cols: 10})
88+ for _, line := range lines {
89+ if visibleWidth(line) > 10 {
90+ t.Fatalf("model overflows narrow terminal: %q", line)
91+ }
92+ }
6393 }
94+}
6495
65- params.Cols = 35
66- lines = StatusBar(params)
67- for _, line := range lines {
68- if strings.Contains(line, "goal:active") {
69- return
96+func TestStatusBarShowsActiveGoal(t *testing.T) {
97+ for _, cols := range []int{100, 20} {
98+ lines := StatusBar(StatusBarParams{Theme: Dark, Model: "gpt-test", GoalStatus: "active", CWD: "/tmp/project", Cols: cols})
99+ if !strings.Contains(stripANSI(strings.Join(lines, "\n")), "goal:active") {
100+ t.Fatalf("status bar = %q, want active goal", lines)
70101 }
71102 }
72- t.Fatalf("narrow status bar = %q, want active goal", lines)
73103 }
74104
75-// TestStatusBarAlwaysTwoLines verifies the status bar always emits
76-// two lines when a cwd is present, regardless of terminal width, and
77-// that the cwd is indented with the 2-space pad so it lines up under
78-// the "(provider)" column on line 1.
79105 func TestStatusBarAlwaysTwoLines(t *testing.T) {
80- // Wide terminal that would previously combine into one line.
81106 lines := StatusBar(StatusBarParams{
82- Theme: Dark,
83- Provider: "anthropic",
84- Model: "claude-opus-4-7",
85- CWD: "/tmp/x",
86- Usage: provider.Usage{
87- InputTokens: 476_000,
88- OutputTokens: 3_400,
89- CostUSD: 1.242,
90- },
91- Subscription: true,
92- ContextUsed: 55_000,
93- ContextMax: 1_000_000,
94- Cols: 500, // very wide
107+ Theme: Dark, Provider: "anthropic", Model: "claude-opus-4-7", CWD: "/tmp/x",
108+ Usage: provider.Usage{InputTokens: 476_000, OutputTokens: 3_400, CostUSD: 1.242},
109+ Subscription: true, ContextUsed: 55_000, ContextMax: 1_000_000, Cols: 500,
95110 })
96- if len(lines) != 2 {
97- t.Fatalf("want 2 lines, got %d: %q", len(lines), lines)
98- }
99- if !strings.Contains(lines[0], "claude-opus-4-7") {
100- t.Errorf("line 1 should contain model, got %q", lines[0])
101- }
102- // Line 2 must start with 2-space indent.
103- if !strings.HasPrefix(lines[1], " ") {
104- t.Errorf("line 2 should start with 2-space indent, got %q", lines[1])
105- }
106- if !strings.Contains(lines[1], "/tmp/x") {
107- t.Errorf("line 2 should contain cwd, got %q", lines[1])
111+ if len(lines) != 2 || !strings.Contains(lines[0], "claude-opus-4-7") || stripANSI(lines[1]) != " /tmp/x" {
112+ t.Fatalf("want model/stats followed by indented cwd: %q", lines)
108113 }
109114 }
110115
111-// TestStatusBarNoCWD verifies an empty cwd stays single-line.
112116 func TestStatusBarNoCWD(t *testing.T) {
113- lines := StatusBar(StatusBarParams{
114- Theme: Dark,
115- Provider: "openai",
116- Model: "gpt-5.4",
117- CWD: "",
118- Cols: 200,
119- })
117+ lines := StatusBar(StatusBarParams{Theme: Dark, Model: "gpt-5.4", Cols: 200})
120118 if len(lines) != 1 {
121- t.Fatalf("empty cwd: want 1 line, got %d: %q", len(lines), lines)
119+ t.Fatalf("empty cwd: want 1 line, got %q", lines)
122120 }
123121 }
124122
125123 func TestStatusBarShowsFastMode(t *testing.T) {
126- lines := StatusBar(StatusBarParams{
127- Theme: Dark,
128- Provider: "openai-codex",
129- Model: "gpt-5.6-luna",
130- FastMode: true,
131- CWD: "/tmp/x",
132- Cols: 200,
133- })
134- if len(lines) != 2 {
135- t.Fatalf("want 2 lines, got %d: %q", len(lines), lines)
136- }
137- plain := stripANSI(lines[0])
138- if !strings.Contains(plain, "fast mode") {
139- t.Fatalf("fast mode should be visible in status bar: %q", plain)
124+ lines := StatusBar(StatusBarParams{Theme: Dark, Model: "gpt-5.6-luna", FastMode: true, CWD: "/tmp/x", Cols: 200})
125+ if len(lines) != 2 || !strings.Contains(stripANSI(lines[0]), "fast mode") {
126+ t.Fatalf("fast mode should be visible: %q", lines)
140127 }
141128 }
142129
143-func TestStatusBarReasoningLevelBetweenModelAndStats(t *testing.T) {
144- lines := StatusBar(StatusBarParams{
145- Theme: Dark,
146- Provider: "openai-codex",
147- Model: "gpt-5.5",
148- Reasoning: "minimum",
149- CWD: "/tmp/x",
150- Usage: provider.Usage{
151- InputTokens: 4_300_000,
152- OutputTokens: 2,
153- },
154- Cols: 500,
155- })
156- if len(lines) != 2 {
157- t.Fatalf("want 2 lines, got %d: %q", len(lines), lines)
158- }
159- plain := stripANSI(lines[0])
160- modelIdx := strings.Index(plain, "(openai-codex) gpt-5.5")
161- reasoningIdx := strings.Index(plain, "reasoning: minimal")
162- statsIdx := strings.Index(plain, "↑4.3M")
163- if modelIdx < 0 || reasoningIdx < 0 || statsIdx < 0 {
164- t.Fatalf("line should contain model, reasoning level, and stats, got %q", plain)
165- }
166- if modelIdx >= reasoningIdx || reasoningIdx >= statsIdx {
167- t.Fatalf("reasoning level should sit between model and stats, got %q", plain)
130+func TestStatusBarReasoningSuffix(t *testing.T) {
131+ for _, tc := range []struct{ level, want string }{
132+ {"medium", "gpt-test:medium"}, {"minimum", "gpt-test:minimal"}, {"xhigh", "gpt-test:xhigh"},
133+ {"", "gpt-test"}, {"off", "gpt-test"},
134+ } {
135+ lines := StatusBar(StatusBarParams{Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Reasoning: tc.level, Cols: 200})
136+ if got := stripANSI(lines[0]); got != " "+tc.want {
137+ t.Fatalf("reasoning %q: got %q, want %q", tc.level, got, tc.want)
138+ }
168139 }
169140 }
170141
171142 func TestStatusBarUsesReasoningMaxThemeColor(t *testing.T) {
172143 th := Dark
173144 th.ThinkingMax = Color256(201)
174- lines := StatusBar(StatusBarParams{
175- Theme: th, Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "max", Cols: 200,
176- })
177- if len(lines) != 1 {
178- t.Fatalf("lines = %d, want 1", len(lines))
179- }
180- if !strings.Contains(lines[0], "\x1b[38;5;201m") || !strings.Contains(stripANSI(lines[0]), "reasoning: max") {
181- t.Fatalf("max reasoning style missing: %q", lines[0])
182- }
183-}
184-
185-func TestStatusBarNarrowKeepsModelAndReasoningTogetherWhenTheyFit(t *testing.T) {
186- lines := StatusBar(StatusBarParams{
187- Theme: Dark,
188- Provider: "openai-codex",
189- Model: "gpt-5.5",
190- Reasoning: "xhigh",
191- CWD: "/tmp/x",
192- Usage: provider.Usage{
193- CostUSD: 0,
194- },
195- Subscription: true,
196- ContextUsed: 100,
197- ContextMax: 1_000_000,
198- Cols: 64,
199- })
200- if len(lines) != 3 {
201- t.Fatalf("narrow status with model+thinking fit: want 3 lines, got %d: %q", len(lines), lines)
202- }
203- plain := make([]string, len(lines))
204- for i, line := range lines {
205- plain[i] = stripANSI(line)
206- }
207- if !strings.Contains(plain[0], "(openai-codex) gpt-5.5 reasoning: xhigh") {
208- t.Fatalf("line 1 should contain model and reasoning level, got %q", plain[0])
209- }
210- if !strings.Contains(plain[1], "$0.000 (sub)") || strings.Contains(plain[1], "reasoning level") {
211- t.Fatalf("line 2 should contain only stats, got %q", plain[1])
212- }
213- if !strings.Contains(plain[2], "/tmp/x") {
214- t.Fatalf("line 3 should contain cwd, got %q", plain[2])
215- }
216-}
217-
218-func TestStatusBarNarrowSplitsAfterReasoningLevel(t *testing.T) {
219- lines := StatusBar(StatusBarParams{
220- Theme: Dark,
221- Provider: "openai-codex",
222- Model: "gpt-5.5",
223- Reasoning: "minimum",
224- CWD: "/tmp/x",
225- Usage: provider.Usage{
226- InputTokens: 4_300_000,
227- OutputTokens: 2,
228- },
229- Cols: 40,
230- })
231- if len(lines) != 4 {
232- t.Fatalf("narrow status with reasoning: want 4 lines, got %d: %q", len(lines), lines)
233- }
234- plain := make([]string, len(lines))
235- for i, line := range lines {
236- plain[i] = stripANSI(line)
237- }
238- if !strings.Contains(plain[0], "(openai-codex) gpt-5.5") {
239- t.Fatalf("line 1 should contain model info, got %q", plain[0])
240- }
241- if !strings.Contains(plain[1], "reasoning: minimal") || strings.Contains(plain[1], "↑4.3M") {
242- t.Fatalf("line 2 should contain only reasoning level, got %q", plain[1])
243- }
244- if !strings.Contains(plain[2], "↑4.3M ↓2") {
245- t.Fatalf("line 3 should contain stats, got %q", plain[2])
246- }
247- if !strings.Contains(plain[3], "/tmp/x") {
248- t.Fatalf("line 4 should contain cwd, got %q", plain[3])
145+ for _, cols := range []int{200, 25} {
146+ lines := StatusBar(StatusBarParams{Theme: th, Model: "gpt-5.6-sol", Reasoning: "max", Cols: cols, WeeklyUsage: "weekly:0%"})
147+ if !strings.Contains(lines[0], "\x1b[38;5;201m") || !strings.Contains(stripANSI(lines[0]), "gpt-5.6-sol:max") {
148+ t.Fatalf("max reasoning style missing: %q", lines)
149+ }
249150 }
250151 }
251152
252-func TestStatusBarVeryNarrowSplitsAfterReasoningLevel(t *testing.T) {
253- lines := StatusBar(StatusBarParams{
254- Theme: Dark,
255- Provider: "openai-codex",
256- Model: "gpt-5.5",
257- Reasoning: "minimum",
258- CWD: "/tmp/x",
259- Usage: provider.Usage{
260- InputTokens: 4_300_000,
261- OutputTokens: 2,
262- },
263- Cols: 32,
264- })
265- if len(lines) != 4 {
266- t.Fatalf("narrow status with reasoning: want 4 lines, got %d: %q", len(lines), lines)
267- }
268- plain := make([]string, len(lines))
269- for i, line := range lines {
270- plain[i] = stripANSI(line)
271- }
272- if !strings.Contains(plain[0], "(openai-codex) gpt-5.5") {
273- t.Fatalf("line 1 should contain model info, got %q", plain[0])
274- }
275- if !strings.Contains(plain[1], "reasoning: minimal") {
276- t.Fatalf("line 2 should contain reasoning level, got %q", plain[1])
277- }
278- if !strings.Contains(plain[2], "↑4.3M ↓2") {
279- t.Fatalf("line 3 should contain stats, got %q", plain[2])
280- }
281- if !strings.Contains(plain[3], "/tmp/x") {
282- t.Fatalf("line 4 should contain cwd, got %q", plain[3])
153+func TestStatusBarContextWarningsAndCompaction(t *testing.T) {
154+ for _, tc := range []struct {
155+ used int
156+ color TerminalColor
157+ }{{75, Dark.Warning}, {95, Dark.Error}} {
158+ lines := StatusBar(StatusBarParams{Theme: Dark, Model: "model", ContextUsed: tc.used, ContextMax: 100, AutoCompacting: true, Cols: 200})
159+ want := Dark.FGColor(tc.color, fmt.Sprintf("ctx%d%%/100 (auto)", tc.used))
160+ if !strings.Contains(lines[0], want) {
161+ t.Fatalf("context warning missing: %q", lines)
162+ }
283163 }
284164 }
285165
286166 func TestStatusBarNoYoloTagPrecedesCWD(t *testing.T) {
287- lines := StatusBar(StatusBarParams{
288- Theme: Dark,
289- Provider: "openai",
290- Model: "gpt-5.5",
291- CWD: "/tmp/x",
292- NoYolo: true,
293- Cols: 200,
294- })
295- if len(lines) != 2 {
296- t.Fatalf("want 2 lines, got %d: %q", len(lines), lines)
297- }
298- plain := stripANSI(lines[1])
299- if !strings.Contains(plain, "yolo mode disabled - /tmp/x") {
300- t.Fatalf("cwd line should include no-yolo tag before cwd, got %q", plain)
167+ lines := StatusBar(StatusBarParams{Theme: Dark, Model: "gpt-5.5", CWD: "/tmp/x", NoYolo: true, Cols: 200})
168+ if len(lines) != 2 || !strings.Contains(stripANSI(lines[1]), "yolo mode disabled - /tmp/x") {
169+ t.Fatalf("cwd line should include no-yolo tag: %q", lines)
301170 }
302171 }
@@ -1,20 +1,42 @@
1 package tui1 package tui
2 2
3 import (3 import (
4+ "fmt"
4 "strings"5 "strings"
5 "testing"6 "testing"
6 7
7 "github.com/bnema/zut/packages/provider"8 "github.com/bnema/zut/packages/provider"
8 )9 )
9 10
11+func TestStatusBarCompact(t *testing.T) {
12+ lines := StatusBar(StatusBarParams{
13+ Theme: Dark, Provider: "openai-codex", Model: "gpt-6-astra", Reasoning: "medium", Cols: 120,
14+ Usage: provider.Usage{InputTokens: 261_000, OutputTokens: 34_000, CacheReadTokens: 12_000_000,
15+ CacheMeasuredPromptTokens: 1000, CacheMeasuredReadTokens: 979, CostUSD: 8.522},
16+ Subscription: true, ContextUsed: 155_000, ContextMax: 500_000, WeeklyUsage: "weekly:69%",
17+ CWD: "/tmp/x",
18+ })
19+ if len(lines) != 2 {
20+ t.Fatalf("want stats + cwd, got %q", lines)
21+ }
22+ want := " gpt-6-astra:medium ↑261k ↓34k R12M C98% $8.52 weekly:69% ctx31%/500k"
23+ if got := stripANSI(lines[0]); got != want {
24+ t.Fatalf("status = %q, want %q", got, want)
25+ }
26+ if got := stripANSI(lines[1]); got != " /tmp/x" {
27+ t.Fatalf("cwd = %q", got)
28+ }
29+}
30+
10 func TestStatusBarShowsCacheHitRatio(t *testing.T) {31 func TestStatusBarShowsCacheHitRatio(t *testing.T) {
11 lines := StatusBar(StatusBarParams{32 lines := StatusBar(StatusBarParams{
12 Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: 200,33 Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: 200,
13- Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000},34+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000,
35+ CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000},
14 ContextUsed: 45_152, ContextMax: 272_000,36 ContextUsed: 45_152, ContextMax: 272_000,
15 })37 })
16 plain := stripANSI(strings.Join(lines, "\n"))38 plain := stripANSI(strings.Join(lines, "\n"))
17- if !strings.Contains(plain, "↑84k ↓1.5k R123k/ C59.4%") {39+ if !strings.Contains(plain, "↑84k ↓1.5k R123k C59%") {
18 t.Fatalf("status bar = %q, want compact cache hit ratio", plain)40 t.Fatalf("status bar = %q, want compact cache hit ratio", plain)
19 }41 }
20 }42 }
@@ -30,273 +52,120 @@ func TestStatusBarOmitsCacheRatioWhenCacheDetailsAreUnavailable(t *testing.T) {
30 }52 }
31 }53 }
32 54
33-func TestStatusBarWrapsCacheStatsWithinNarrowWidth(t *testing.T) {55+func TestStatusBarNarrow(t *testing.T) {
34- const cols = 3056+ for _, cols := range []int{24, 30, 32, 40, 64} {
35- lines := StatusBar(StatusBarParams{57+ for _, busy := range []string{"", "⠋ Working"} {
36- Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: cols,58+ t.Run(fmt.Sprintf("%d/busy=%t", cols, busy != ""), func(t *testing.T) {
37- Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheWriteTokens: 2_000, CacheMeasuredPromptTokens: 209_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},59+ lines := StatusBar(StatusBarParams{
38- ContextUsed: 209_000, ContextMax: 272_000,60+ Theme: Dark, Provider: "openai-codex", Model: "gpt-5.5", Reasoning: "minimum", Cols: cols,
39- })61+ BusyPrefix: busy, CWD: "/tmp/x", FastMode: true, WeeklyUsage: "weekly:69%",
40- plain := stripANSI(strings.Join(lines, "\n"))62+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000,
41- if !strings.Contains(plain, "C58.9%") {63+ CacheWriteTokens: 2_000, CacheMeasuredPromptTokens: 209_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
42- t.Fatalf("narrow status bar = %q, want cache hit ratio", plain)64+ ContextUsed: 209_000, ContextMax: 272_000,
43- }65+ })
44- for _, line := range lines {66+ plain := stripANSI(strings.Join(lines, "\n"))
45- if width := visibleWidth(line); width > cols {67+ for _, want := range []string{"gpt-5.5:minimal", "fast mode", "↑84k", "↓1.5k", "R123k", "C59%", "W2.0k", "$0.53", "weekly:69%", "ctx77%/272k", "/tmp/x"} {
46- t.Fatalf("narrow status line width = %d, want <= %d: %q", width, cols, stripANSI(line))68+ if !strings.Contains(plain, want) {
69+ t.Fatalf("missing %q in %q", want, plain)
70+ }
71+ }
72+ for _, line := range lines {
73+ if width := visibleWidth(line); width > cols {
74+ t.Fatalf("line width = %d, want <= %d: %q", width, cols, stripANSI(line))
75+ }
76+ }
77+ if busy != "" && stripANSI(lines[0]) != " "+busy {
78+ t.Fatalf("busy prefix should stay on its own row: %q", lines)
79+ }
80+ })
47 }81 }
48 }82 }
49 }83 }
50 84
51-func TestStatusBarShowsActiveGoal(t *testing.T) {85+func TestStatusBarVeryNarrowModel(t *testing.T) {
52- params := StatusBarParams{86+ for _, busy := range []string{"", "⠋"} {
53- Theme: Dark,87+ lines := StatusBar(StatusBarParams{Theme: Dark, Model: "模型-with-long-name", Reasoning: "max", BusyPrefix: busy, Cols: 10})
54- Provider: "openai",88+ for _, line := range lines {
55- Model: "gpt-test",89+ if visibleWidth(line) > 10 {
56- GoalStatus: "active",90+ t.Fatalf("model overflows narrow terminal: %q", line)
57- CWD: "/tmp/project",91+ }
58- Cols: 100,92+ }
59- }
60- lines := StatusBar(params)
61- if len(lines) == 0 || !strings.Contains(lines[0], "goal:active") {
62- t.Fatalf("status bar = %q, want active goal", lines)
63 }93 }
94+}
64 95
65- params.Cols = 3596+func TestStatusBarShowsActiveGoal(t *testing.T) {
66- lines = StatusBar(params)97+ for _, cols := range []int{100, 20} {
67- for _, line := range lines {98+ lines := StatusBar(StatusBarParams{Theme: Dark, Model: "gpt-test", GoalStatus: "active", CWD: "/tmp/project", Cols: cols})
68- if strings.Contains(line, "goal:active") {99+ if !strings.Contains(stripANSI(strings.Join(lines, "\n")), "goal:active") {
69- return100+ t.Fatalf("status bar = %q, want active goal", lines)
70 }101 }
71 }102 }
72- t.Fatalf("narrow status bar = %q, want active goal", lines)
73 }103 }
74 104
75-// TestStatusBarAlwaysTwoLines verifies the status bar always emits
76-// two lines when a cwd is present, regardless of terminal width, and
77-// that the cwd is indented with the 2-space pad so it lines up under
78-// the "(provider)" column on line 1.
79 func TestStatusBarAlwaysTwoLines(t *testing.T) {105 func TestStatusBarAlwaysTwoLines(t *testing.T) {
80- // Wide terminal that would previously combine into one line.
81 lines := StatusBar(StatusBarParams{106 lines := StatusBar(StatusBarParams{
82- Theme: Dark,107+ Theme: Dark, Provider: "anthropic", Model: "claude-opus-4-7", CWD: "/tmp/x",
83- Provider: "anthropic",108+ Usage: provider.Usage{InputTokens: 476_000, OutputTokens: 3_400, CostUSD: 1.242},
84- Model: "claude-opus-4-7",109+ Subscription: true, ContextUsed: 55_000, ContextMax: 1_000_000, Cols: 500,
85- CWD: "/tmp/x",
86- Usage: provider.Usage{
87- InputTokens: 476_000,
88- OutputTokens: 3_400,
89- CostUSD: 1.242,
90- },
91- Subscription: true,
92- ContextUsed: 55_000,
93- ContextMax: 1_000_000,
94- Cols: 500, // very wide
95 })110 })
96- if len(lines) != 2 {111+ if len(lines) != 2 || !strings.Contains(lines[0], "claude-opus-4-7") || stripANSI(lines[1]) != " /tmp/x" {
97- t.Fatalf("want 2 lines, got %d: %q", len(lines), lines)112+ t.Fatalf("want model/stats followed by indented cwd: %q", lines)
98- }
99- if !strings.Contains(lines[0], "claude-opus-4-7") {
100- t.Errorf("line 1 should contain model, got %q", lines[0])
101- }
102- // Line 2 must start with 2-space indent.
103- if !strings.HasPrefix(lines[1], " ") {
104- t.Errorf("line 2 should start with 2-space indent, got %q", lines[1])
105- }
106- if !strings.Contains(lines[1], "/tmp/x") {
107- t.Errorf("line 2 should contain cwd, got %q", lines[1])
108 }113 }
109 }114 }
110 115
111-// TestStatusBarNoCWD verifies an empty cwd stays single-line.
112 func TestStatusBarNoCWD(t *testing.T) {116 func TestStatusBarNoCWD(t *testing.T) {
113- lines := StatusBar(StatusBarParams{117+ lines := StatusBar(StatusBarParams{Theme: Dark, Model: "gpt-5.4", Cols: 200})
114- Theme: Dark,
115- Provider: "openai",
116- Model: "gpt-5.4",
117- CWD: "",
118- Cols: 200,
119- })
120 if len(lines) != 1 {118 if len(lines) != 1 {
121- t.Fatalf("empty cwd: want 1 line, got %d: %q", len(lines), lines)119+ t.Fatalf("empty cwd: want 1 line, got %q", lines)
122 }120 }
123 }121 }
124 122
125 func TestStatusBarShowsFastMode(t *testing.T) {123 func TestStatusBarShowsFastMode(t *testing.T) {
126- lines := StatusBar(StatusBarParams{124+ lines := StatusBar(StatusBarParams{Theme: Dark, Model: "gpt-5.6-luna", FastMode: true, CWD: "/tmp/x", Cols: 200})
127- Theme: Dark,125+ if len(lines) != 2 || !strings.Contains(stripANSI(lines[0]), "fast mode") {
128- Provider: "openai-codex",126+ t.Fatalf("fast mode should be visible: %q", lines)
129- Model: "gpt-5.6-luna",
130- FastMode: true,
131- CWD: "/tmp/x",
132- Cols: 200,
133- })
134- if len(lines) != 2 {
135- t.Fatalf("want 2 lines, got %d: %q", len(lines), lines)
136- }
137- plain := stripANSI(lines[0])
138- if !strings.Contains(plain, "fast mode") {
139- t.Fatalf("fast mode should be visible in status bar: %q", plain)
140 }127 }
141 }128 }
142 129
143-func TestStatusBarReasoningLevelBetweenModelAndStats(t *testing.T) {130+func TestStatusBarReasoningSuffix(t *testing.T) {
144- lines := StatusBar(StatusBarParams{131+ for _, tc := range []struct{ level, want string }{
145- Theme: Dark,132+ {"medium", "gpt-test:medium"}, {"minimum", "gpt-test:minimal"}, {"xhigh", "gpt-test:xhigh"},
146- Provider: "openai-codex",133+ {"", "gpt-test"}, {"off", "gpt-test"},
147- Model: "gpt-5.5",134+ } {
148- Reasoning: "minimum",135+ lines := StatusBar(StatusBarParams{Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Reasoning: tc.level, Cols: 200})
149- CWD: "/tmp/x",136+ if got := stripANSI(lines[0]); got != " "+tc.want {
150- Usage: provider.Usage{137+ t.Fatalf("reasoning %q: got %q, want %q", tc.level, got, tc.want)
151- InputTokens: 4_300_000,138+ }
152- OutputTokens: 2,
153- },
154- Cols: 500,
155- })
156- if len(lines) != 2 {
157- t.Fatalf("want 2 lines, got %d: %q", len(lines), lines)
158- }
159- plain := stripANSI(lines[0])
160- modelIdx := strings.Index(plain, "(openai-codex) gpt-5.5")
161- reasoningIdx := strings.Index(plain, "reasoning: minimal")
162- statsIdx := strings.Index(plain, "↑4.3M")
163- if modelIdx < 0 || reasoningIdx < 0 || statsIdx < 0 {
164- t.Fatalf("line should contain model, reasoning level, and stats, got %q", plain)
165- }
166- if modelIdx >= reasoningIdx || reasoningIdx >= statsIdx {
167- t.Fatalf("reasoning level should sit between model and stats, got %q", plain)
168 }139 }
169 }140 }
170 141
171 func TestStatusBarUsesReasoningMaxThemeColor(t *testing.T) {142 func TestStatusBarUsesReasoningMaxThemeColor(t *testing.T) {
172 th := Dark143 th := Dark
173 th.ThinkingMax = Color256(201)144 th.ThinkingMax = Color256(201)
174- lines := StatusBar(StatusBarParams{145+ for _, cols := range []int{200, 25} {
175- Theme: th, Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "max", Cols: 200,146+ lines := StatusBar(StatusBarParams{Theme: th, Model: "gpt-5.6-sol", Reasoning: "max", Cols: cols, WeeklyUsage: "weekly:0%"})
176- })147+ if !strings.Contains(lines[0], "\x1b[38;5;201m") || !strings.Contains(stripANSI(lines[0]), "gpt-5.6-sol:max") {
177- if len(lines) != 1 {148+ t.Fatalf("max reasoning style missing: %q", lines)
178- t.Fatalf("lines = %d, want 1", len(lines))149+ }
179- }
180- if !strings.Contains(lines[0], "\x1b[38;5;201m") || !strings.Contains(stripANSI(lines[0]), "reasoning: max") {
181- t.Fatalf("max reasoning style missing: %q", lines[0])
182- }
183-}
184-
185-func TestStatusBarNarrowKeepsModelAndReasoningTogetherWhenTheyFit(t *testing.T) {
186- lines := StatusBar(StatusBarParams{
187- Theme: Dark,
188- Provider: "openai-codex",
189- Model: "gpt-5.5",
190- Reasoning: "xhigh",
191- CWD: "/tmp/x",
192- Usage: provider.Usage{
193- CostUSD: 0,
194- },
195- Subscription: true,
196- ContextUsed: 100,
197- ContextMax: 1_000_000,
198- Cols: 64,
199- })
200- if len(lines) != 3 {
201- t.Fatalf("narrow status with model+thinking fit: want 3 lines, got %d: %q", len(lines), lines)
202- }
203- plain := make([]string, len(lines))
204- for i, line := range lines {
205- plain[i] = stripANSI(line)
206- }
207- if !strings.Contains(plain[0], "(openai-codex) gpt-5.5 reasoning: xhigh") {
208- t.Fatalf("line 1 should contain model and reasoning level, got %q", plain[0])
209- }
210- if !strings.Contains(plain[1], "$0.000 (sub)") || strings.Contains(plain[1], "reasoning level") {
211- t.Fatalf("line 2 should contain only stats, got %q", plain[1])
212- }
213- if !strings.Contains(plain[2], "/tmp/x") {
214- t.Fatalf("line 3 should contain cwd, got %q", plain[2])
215- }
216-}
217-
218-func TestStatusBarNarrowSplitsAfterReasoningLevel(t *testing.T) {
219- lines := StatusBar(StatusBarParams{
220- Theme: Dark,
221- Provider: "openai-codex",
222- Model: "gpt-5.5",
223- Reasoning: "minimum",
224- CWD: "/tmp/x",
225- Usage: provider.Usage{
226- InputTokens: 4_300_000,
227- OutputTokens: 2,
228- },
229- Cols: 40,
230- })
231- if len(lines) != 4 {
232- t.Fatalf("narrow status with reasoning: want 4 lines, got %d: %q", len(lines), lines)
233- }
234- plain := make([]string, len(lines))
235- for i, line := range lines {
236- plain[i] = stripANSI(line)
237- }
238- if !strings.Contains(plain[0], "(openai-codex) gpt-5.5") {
239- t.Fatalf("line 1 should contain model info, got %q", plain[0])
240- }
241- if !strings.Contains(plain[1], "reasoning: minimal") || strings.Contains(plain[1], "↑4.3M") {
242- t.Fatalf("line 2 should contain only reasoning level, got %q", plain[1])
243- }
244- if !strings.Contains(plain[2], "↑4.3M ↓2") {
245- t.Fatalf("line 3 should contain stats, got %q", plain[2])
246- }
247- if !strings.Contains(plain[3], "/tmp/x") {
248- t.Fatalf("line 4 should contain cwd, got %q", plain[3])
249 }150 }
250 }151 }
251 152
252-func TestStatusBarVeryNarrowSplitsAfterReasoningLevel(t *testing.T) {153+func TestStatusBarContextWarningsAndCompaction(t *testing.T) {
253- lines := StatusBar(StatusBarParams{154+ for _, tc := range []struct {
254- Theme: Dark,155+ used int
255- Provider: "openai-codex",156+ color TerminalColor
256- Model: "gpt-5.5",157+ }{{75, Dark.Warning}, {95, Dark.Error}} {
257- Reasoning: "minimum",158+ lines := StatusBar(StatusBarParams{Theme: Dark, Model: "model", ContextUsed: tc.used, ContextMax: 100, AutoCompacting: true, Cols: 200})
258- CWD: "/tmp/x",159+ want := Dark.FGColor(tc.color, fmt.Sprintf("ctx%d%%/100 (auto)", tc.used))
259- Usage: provider.Usage{160+ if !strings.Contains(lines[0], want) {
260- InputTokens: 4_300_000,161+ t.Fatalf("context warning missing: %q", lines)
261- OutputTokens: 2,162+ }
262- },
263- Cols: 32,
264- })
265- if len(lines) != 4 {
266- t.Fatalf("narrow status with reasoning: want 4 lines, got %d: %q", len(lines), lines)
267- }
268- plain := make([]string, len(lines))
269- for i, line := range lines {
270- plain[i] = stripANSI(line)
271- }
272- if !strings.Contains(plain[0], "(openai-codex) gpt-5.5") {
273- t.Fatalf("line 1 should contain model info, got %q", plain[0])
274- }
275- if !strings.Contains(plain[1], "reasoning: minimal") {
276- t.Fatalf("line 2 should contain reasoning level, got %q", plain[1])
277- }
278- if !strings.Contains(plain[2], "↑4.3M ↓2") {
279- t.Fatalf("line 3 should contain stats, got %q", plain[2])
280- }
281- if !strings.Contains(plain[3], "/tmp/x") {
282- t.Fatalf("line 4 should contain cwd, got %q", plain[3])
283 }163 }
284 }164 }
285 165
286 func TestStatusBarNoYoloTagPrecedesCWD(t *testing.T) {166 func TestStatusBarNoYoloTagPrecedesCWD(t *testing.T) {
287- lines := StatusBar(StatusBarParams{167+ lines := StatusBar(StatusBarParams{Theme: Dark, Model: "gpt-5.5", CWD: "/tmp/x", NoYolo: true, Cols: 200})
288- Theme: Dark,168+ if len(lines) != 2 || !strings.Contains(stripANSI(lines[1]), "yolo mode disabled - /tmp/x") {
289- Provider: "openai",169+ t.Fatalf("cwd line should include no-yolo tag: %q", lines)
290- Model: "gpt-5.5",
291- CWD: "/tmp/x",
292- NoYolo: true,
293- Cols: 200,
294- })
295- if len(lines) != 2 {
296- t.Fatalf("want 2 lines, got %d: %q", len(lines), lines)
297- }
298- plain := stripANSI(lines[1])
299- if !strings.Contains(plain, "yolo mode disabled - /tmp/x") {
300- t.Fatalf("cwd line should include no-yolo tag before cwd, got %q", plain)
301 }170 }
302 }171 }
M packages/tui/usage.go +17 -4
@@ -10,6 +10,7 @@ import (
1010 type UsageStatsParams struct {
1111 Usage provider.Usage
1212 Subscription bool
13+ Compact bool // main status bar; detailed surfaces retain billing labels and precision
1314 }
1415
1516 // UsageStatsParts returns compact, provider-neutral token and cost labels.
@@ -22,17 +23,29 @@ func UsageStatsParts(p UsageStatsParams) []string {
2223 parts = append(parts, fmt.Sprintf("↓%s", formatTokens(p.Usage.OutputTokens)))
2324 }
2425 if p.Usage.CacheReadTokens > 0 {
25- parts = append(parts, fmt.Sprintf("R%s/", formatTokens(p.Usage.CacheReadTokens)))
26+ read := "R" + formatTokens(p.Usage.CacheReadTokens)
27+ if !p.Compact {
28+ read += "/"
29+ }
30+ parts = append(parts, read)
2631 }
2732 if ratio, ok := p.Usage.CacheHitRatio(); ok {
28- parts = append(parts, fmt.Sprintf("C%.1f%%", ratio*100))
33+ precision := 1
34+ if p.Compact {
35+ precision = 0
36+ }
37+ parts = append(parts, fmt.Sprintf("C%.*f%%", precision, ratio*100))
2938 }
3039 if p.Usage.CacheWriteTokens > 0 {
3140 parts = append(parts, fmt.Sprintf("W%s", formatTokens(p.Usage.CacheWriteTokens)))
3241 }
3342 if p.Usage.CostUSD > 0 || p.Subscription {
34- cost := fmt.Sprintf("$%.3f", p.Usage.CostUSD)
35- if p.Subscription {
43+ precision := 3
44+ if p.Compact {
45+ precision = 2
46+ }
47+ cost := fmt.Sprintf("$%.*f", precision, p.Usage.CostUSD)
48+ if p.Subscription && !p.Compact {
3649 cost += " (sub)"
3750 }
3851 parts = append(parts, cost)
@@ -10,6 +10,7 @@ import (
10 type UsageStatsParams struct {10 type UsageStatsParams struct {
11 Usage provider.Usage11 Usage provider.Usage
12 Subscription bool12 Subscription bool
13+ Compact bool // main status bar; detailed surfaces retain billing labels and precision
13 }14 }
14 15
15 // UsageStatsParts returns compact, provider-neutral token and cost labels.16 // UsageStatsParts returns compact, provider-neutral token and cost labels.
@@ -22,17 +23,29 @@ func UsageStatsParts(p UsageStatsParams) []string {
22 parts = append(parts, fmt.Sprintf("↓%s", formatTokens(p.Usage.OutputTokens)))23 parts = append(parts, fmt.Sprintf("↓%s", formatTokens(p.Usage.OutputTokens)))
23 }24 }
24 if p.Usage.CacheReadTokens > 0 {25 if p.Usage.CacheReadTokens > 0 {
25- parts = append(parts, fmt.Sprintf("R%s/", formatTokens(p.Usage.CacheReadTokens)))26+ read := "R" + formatTokens(p.Usage.CacheReadTokens)
27+ if !p.Compact {
28+ read += "/"
29+ }
30+ parts = append(parts, read)
26 }31 }
27 if ratio, ok := p.Usage.CacheHitRatio(); ok {32 if ratio, ok := p.Usage.CacheHitRatio(); ok {
28- parts = append(parts, fmt.Sprintf("C%.1f%%", ratio*100))33+ precision := 1
34+ if p.Compact {
35+ precision = 0
36+ }
37+ parts = append(parts, fmt.Sprintf("C%.*f%%", precision, ratio*100))
29 }38 }
30 if p.Usage.CacheWriteTokens > 0 {39 if p.Usage.CacheWriteTokens > 0 {
31 parts = append(parts, fmt.Sprintf("W%s", formatTokens(p.Usage.CacheWriteTokens)))40 parts = append(parts, fmt.Sprintf("W%s", formatTokens(p.Usage.CacheWriteTokens)))
32 }41 }
33 if p.Usage.CostUSD > 0 || p.Subscription {42 if p.Usage.CostUSD > 0 || p.Subscription {
34- cost := fmt.Sprintf("$%.3f", p.Usage.CostUSD)43+ precision := 3
35- if p.Subscription {44+ if p.Compact {
45+ precision = 2
46+ }
47+ cost := fmt.Sprintf("$%.*f", precision, p.Usage.CostUSD)
48+ if p.Subscription && !p.Compact {
36 cost += " (sub)"49 cost += " (sub)"
37 }50 }
38 parts = append(parts, cost)51 parts = append(parts, cost)
M packages/tui/view.go +42 -63
@@ -2992,10 +2992,13 @@ type StatusBarParams struct {
29922992 Usage provider.Usage
29932993 // Subscription is true when the credential is an OAuth token (claude
29942994 // pro/max, chatgpt plus/pro) rather than a paid api key. We still
2995- // compute a cost for visibility and append "(sub)" so the user
2996- // knows no real money moved.
2995+ // compute an API-equivalent cost for visibility, not an actual charge.
29972996 Subscription bool
29982997
2998+ // WeeklyUsage is the account's remaining weekly allowance, already
2999+ // formatted by the caller (empty for providers without this meter).
3000+ WeeklyUsage string
3001+
29993002 // Last turn's input+cache tokens (approximates current live context).
30003003 ContextUsed int
30013004 ContextMax int // model's context window; 0 disables the percentage
@@ -3010,19 +3013,16 @@ type StatusBarParams struct {
30103013 // glance that dms are being mirrored into this session.
30113014 Telegram bool
30123015
3013- Cols int // terminal width; drives right-alignment of cwd
3016+ Cols int // terminal width; drives status wrapping
30143017 }
30153018
3016-// StatusBar builds the status shown above the editor. Always returns
3017-// two lines when a cwd is provided: the stats on the first line, the
3018-// cwd on its own line below, indented to match the stats column. This
3019-// keeps the status bar stable across terminal resizes (the cwd never
3020-// jumps from right-aligned-on-line-1 to flush-left-on-line-2) and
3021-// makes a long cwd safe at any width.
3019+// StatusBar builds the status shown above the editor. The cwd stays on its
3020+// own final line, indented to match the stats column. Narrow terminals split
3021+// model and usage onto additional lines rather than hard-wrapping metrics.
30223022 //
30233023 // Layout:
30243024 //
3025-// <busyPrefix> (provider) model stats <- line 1
3025+// <busyPrefix> model:reasoning stats <- line 1
30263026 // cwd <- line 2 (2-space indent)
30273027 //
30283028 // The old "ctrl+c exit - /help" / "esc cancel" hint is gone entirely.
@@ -3031,7 +3031,10 @@ type StatusBarParams struct {
30313031 func StatusBar(p StatusBarParams) []string {
30323032 th := p.Theme
30333033
3034- stats := UsageStatsParts(UsageStatsParams{Usage: p.Usage, Subscription: p.Subscription})
3034+ stats := UsageStatsParts(UsageStatsParams{Usage: p.Usage, Subscription: p.Subscription, Compact: true})
3035+ if p.WeeklyUsage != "" {
3036+ stats = append(stats, p.WeeklyUsage)
3037+ }
30353038 if p.GoalStatus != "" {
30363039 stats = append(stats, "goal:"+p.GoalStatus)
30373040 }
@@ -3039,6 +3042,10 @@ func StatusBar(p StatusBarParams) []string {
30393042 // Context %. Color-coded: yellow >70, red >90.
30403043 ctx, ctxColor := contextUsage(th, p.ContextUsed, p.ContextMax)
30413044 if ctx != "" {
3045+ if p.ContextMax > 0 {
3046+ ctx = fmt.Sprintf("%.0f%%/%s", float64(p.ContextUsed)/float64(p.ContextMax)*100, formatTokens(p.ContextMax))
3047+ }
3048+ ctx = "ctx" + ctx
30423049 if p.AutoCompacting {
30433050 ctx += " (auto)"
30443051 }
@@ -3046,17 +3053,16 @@ func StatusBar(p StatusBarParams) []string {
30463053 }
30473054
30483055 // Layout uses exactly 2 spaces of horizontal padding everywhere:
3049- // 2 spaces (openai) gpt-5.4 $0.000 (sub) 0.0%/400k ~/Sites/zut
3056+ // 2 spaces gpt-5.4:medium $0.00 ctx0%/400k
30503057 // matches the editor prompt's left inset so the bar lines up
30513058 // vertically with the conversation column.
30523059 const pad = " " // 2 spaces
30533060
3054- left := fmt.Sprintf("(%s) %s", p.Provider, p.Model)
3055- reasoning := reasoningLevelLabel(p.Reasoning)
3056- reasoningText := ""
3057- if reasoning != "" {
3058- reasoningText = "reasoning: " + reasoning
3061+ left := p.Model
3062+ if reasoning := reasoningLevelLabel(p.Reasoning); reasoning != "" {
3063+ left += ":" + reasoning
30593064 }
3065+ modelColor := reasoningStatusColor(th, left)
30603066 fastText := ""
30613067 if p.FastMode {
30623068 fastText = "fast mode"
@@ -3066,9 +3072,6 @@ func StatusBar(p StatusBarParams) []string {
30663072 if fastText != "" {
30673073 middleParts = append(middleParts, fastText)
30683074 }
3069- if reasoningText != "" {
3070- middleParts = append(middleParts, reasoningText)
3071- }
30723075 if statsText != "" {
30733076 middleParts = append(middleParts, statsText)
30743077 }
@@ -3082,7 +3085,7 @@ func StatusBar(p StatusBarParams) []string {
30823085 // those choices. The pad itself needs no color (it's spaces).
30833086 leftBuilder.WriteString(pad + p.BusyPrefix)
30843087 // Exactly one pad (2 spaces) between the busy segment and
3085- // the provider/model block. The leading pad above covers
3088+ // the model block. The leading pad above covers
30863089 // the left indent.
30873090 leftBuilder.WriteString(pad)
30883091 } else {
@@ -3092,11 +3095,10 @@ func StatusBar(p StatusBarParams) []string {
30923095 // prefix there's no trailing separator to double-pad.
30933096 leftBuilder.WriteString(pad)
30943097 }
3095- leftBuilder.WriteString(th.FGColor(th.Muted, left))
3098+ leftBuilder.WriteString(th.FGColor(modelColor, left))
30963099 if middle != "" {
30973100 leftBuilder.WriteString(pad)
3098- // Highlight the opt-in max tier; other status information stays muted.
3099- leftBuilder.WriteString(th.FGColor(reasoningStatusColor(th, reasoningText), middle))
3101+ leftBuilder.WriteString(th.FGColor(th.Muted, middle))
31003102 }
31013103
31023104 cwd := shortenHome(p.CWD)
@@ -3122,16 +3124,16 @@ func StatusBar(p StatusBarParams) []string {
31223124 // stats on their own rows. This mirrors the idle split below.
31233125 if p.Cols > 0 && p.BusyPrefix != "" && visibleWidth(primary) > p.Cols {
31243126 busyLine := pad + p.BusyPrefix
3125- modelLine := pad + th.FGColor(th.Muted, left)
3127+ modelLine := pad + th.FGColor(modelColor, left)
31263128 lines := []string{busyLine}
3127- if middle != "" && visibleWidth(modelLine+pad+th.FGColor(th.Muted, middle)) > p.Cols {
3128- lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, stats, p.Cols)
3129+ if visibleWidth(modelLine+pad+middle) > p.Cols {
3130+ lines = appendWrappedStatusLines(lines, th, pad, left, fastText, stats, p.Cols)
31293131 } else {
31303132 var infoBuilder strings.Builder
31313133 infoBuilder.WriteString(modelLine)
31323134 if middle != "" {
31333135 infoBuilder.WriteString(pad)
3134- infoBuilder.WriteString(th.FGColor(reasoningStatusColor(th, reasoningText), middle))
3136+ infoBuilder.WriteString(th.FGColor(th.Muted, middle))
31353137 }
31363138 lines = append(lines, infoBuilder.String())
31373139 }
@@ -3141,13 +3143,13 @@ func StatusBar(p StatusBarParams) []string {
31413143 return lines
31423144 }
31433145
3144- // Idle narrow split: keep provider/model on the first status line,
3146+ // Idle narrow split: keep model/reasoning on the first status line,
31453147 // move usage/cost/context stats to the next, then cwd below. This
31463148 // avoids the terminal's hard wrap cutting the stats or pushing cwd
31473149 // into an awkward position on small widths.
3148- if p.Cols > 0 && p.BusyPrefix == "" && middle != "" && visibleWidth(primary) > p.Cols {
3150+ if p.Cols > 0 && p.BusyPrefix == "" && visibleWidth(primary) > p.Cols {
31493151 var lines []string
3150- lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, stats, p.Cols)
3152+ lines = appendWrappedStatusLines(lines, th, pad, left, fastText, stats, p.Cols)
31513153 if cwd != "" {
31523154 lines = append(lines, pad+th.FGColor(th.Muted, cwd))
31533155 }
@@ -3159,42 +3161,19 @@ func StatusBar(p StatusBarParams) []string {
31593161 }
31603162
31613163 // Second line: indent with the same 2-space pad so the cwd lines
3162- // up under the "(provider)" column on line 1.
3164+ // up under the model column on line 1.
31633165 cwdRendered := pad + th.FGColor(th.Muted, cwd)
31643166 return []string{primary, cwdRendered}
31653167 }
31663168
3167-func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText, reasoningText string, stats []string, cols int) []string {
3168- modelLine := pad + th.FGColor(th.Muted, modelText)
3169- infoParts := make([]string, 0, 2)
3170- if fastText != "" {
3171- infoParts = append(infoParts, fastText)
3172- }
3173- if reasoningText != "" {
3174- infoParts = append(infoParts, reasoningText)
3175- }
3176- infoText := strings.Join(infoParts, pad)
3177- if infoText == "" {
3178- lines = append(lines, modelLine)
3179- return appendWrappedUsageStats(lines, th, pad, stats, cols)
3180- }
3181-
3182- modelInfoPlain := pad + modelText + pad + infoText
3183- infoColor := th.Muted
3184- if reasoningText != "" {
3185- infoColor = reasoningStatusColor(th, reasoningText)
3186- }
3187- if cols <= 0 || visibleWidth(modelInfoPlain) <= cols {
3188- lines = append(lines, pad+th.FGColor(infoColor, modelText+pad+infoText))
3189- } else {
3190- lines = append(lines, modelLine)
3191- if fastText != "" {
3192- lines = append(lines, pad+th.FGColor(th.Muted, fastText))
3193- }
3194- if reasoningText != "" {
3195- lines = append(lines, pad+th.FGColor(reasoningStatusColor(th, reasoningText), reasoningText))
3196- }
3169+func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText string, stats []string, cols int) []string {
3170+ modelLine := pad + th.FGColor(reasoningStatusColor(th, modelText), modelText)
3171+ if fastText != "" && visibleWidth(modelLine+pad+fastText) <= cols {
3172+ modelLine += pad + th.FGColor(th.Muted, fastText)
3173+ } else if fastText != "" {
3174+ stats = append([]string{fastText}, stats...)
31973175 }
3176+ lines = append(lines, truncateToWidth(modelLine, cols))
31983177 return appendWrappedUsageStats(lines, th, pad, stats, cols)
31993178 }
32003179
@@ -3227,7 +3206,7 @@ func appendWrappedUsageStats(lines []string, th Theme, pad string, stats []strin
32273206 }
32283207
32293208 func reasoningStatusColor(th Theme, reasoningText string) TerminalColor {
3230- if strings.HasSuffix(reasoningText, ": max") {
3209+ if strings.HasSuffix(reasoningText, ":max") {
32313210 return th.ThinkingMax
32323211 }
32333212 return th.Muted
@@ -2992,10 +2992,13 @@ type StatusBarParams struct {
2992 Usage provider.Usage2992 Usage provider.Usage
2993 // Subscription is true when the credential is an OAuth token (claude2993 // Subscription is true when the credential is an OAuth token (claude
2994 // pro/max, chatgpt plus/pro) rather than a paid api key. We still2994 // pro/max, chatgpt plus/pro) rather than a paid api key. We still
2995- // compute a cost for visibility and append "(sub)" so the user2995+ // compute an API-equivalent cost for visibility, not an actual charge.
2996- // knows no real money moved.
2997 Subscription bool2996 Subscription bool
2998 2997
2998+ // WeeklyUsage is the account's remaining weekly allowance, already
2999+ // formatted by the caller (empty for providers without this meter).
3000+ WeeklyUsage string
3001+
2999 // Last turn's input+cache tokens (approximates current live context).3002 // Last turn's input+cache tokens (approximates current live context).
3000 ContextUsed int3003 ContextUsed int
3001 ContextMax int // model's context window; 0 disables the percentage3004 ContextMax int // model's context window; 0 disables the percentage
@@ -3010,19 +3013,16 @@ type StatusBarParams struct {
3010 // glance that dms are being mirrored into this session.3013 // glance that dms are being mirrored into this session.
3011 Telegram bool3014 Telegram bool
3012 3015
3013- Cols int // terminal width; drives right-alignment of cwd3016+ Cols int // terminal width; drives status wrapping
3014 }3017 }
3015 3018
3016-// StatusBar builds the status shown above the editor. Always returns3019+// StatusBar builds the status shown above the editor. The cwd stays on its
3017-// two lines when a cwd is provided: the stats on the first line, the3020+// own final line, indented to match the stats column. Narrow terminals split
3018-// cwd on its own line below, indented to match the stats column. This3021+// model and usage onto additional lines rather than hard-wrapping metrics.
3019-// keeps the status bar stable across terminal resizes (the cwd never
3020-// jumps from right-aligned-on-line-1 to flush-left-on-line-2) and
3021-// makes a long cwd safe at any width.
3022 //3022 //
3023 // Layout:3023 // Layout:
3024 //3024 //
3025-// <busyPrefix> (provider) model stats <- line 13025+// <busyPrefix> model:reasoning stats <- line 1
3026 // cwd <- line 2 (2-space indent)3026 // cwd <- line 2 (2-space indent)
3027 //3027 //
3028 // The old "ctrl+c exit - /help" / "esc cancel" hint is gone entirely.3028 // The old "ctrl+c exit - /help" / "esc cancel" hint is gone entirely.
@@ -3031,7 +3031,10 @@ type StatusBarParams struct {
3031 func StatusBar(p StatusBarParams) []string {3031 func StatusBar(p StatusBarParams) []string {
3032 th := p.Theme3032 th := p.Theme
3033 3033
3034- stats := UsageStatsParts(UsageStatsParams{Usage: p.Usage, Subscription: p.Subscription})3034+ stats := UsageStatsParts(UsageStatsParams{Usage: p.Usage, Subscription: p.Subscription, Compact: true})
3035+ if p.WeeklyUsage != "" {
3036+ stats = append(stats, p.WeeklyUsage)
3037+ }
3035 if p.GoalStatus != "" {3038 if p.GoalStatus != "" {
3036 stats = append(stats, "goal:"+p.GoalStatus)3039 stats = append(stats, "goal:"+p.GoalStatus)
3037 }3040 }
@@ -3039,6 +3042,10 @@ func StatusBar(p StatusBarParams) []string {
3039 // Context %. Color-coded: yellow >70, red >90.3042 // Context %. Color-coded: yellow >70, red >90.
3040 ctx, ctxColor := contextUsage(th, p.ContextUsed, p.ContextMax)3043 ctx, ctxColor := contextUsage(th, p.ContextUsed, p.ContextMax)
3041 if ctx != "" {3044 if ctx != "" {
3045+ if p.ContextMax > 0 {
3046+ ctx = fmt.Sprintf("%.0f%%/%s", float64(p.ContextUsed)/float64(p.ContextMax)*100, formatTokens(p.ContextMax))
3047+ }
3048+ ctx = "ctx" + ctx
3042 if p.AutoCompacting {3049 if p.AutoCompacting {
3043 ctx += " (auto)"3050 ctx += " (auto)"
3044 }3051 }
@@ -3046,17 +3053,16 @@ func StatusBar(p StatusBarParams) []string {
3046 }3053 }
3047 3054
3048 // Layout uses exactly 2 spaces of horizontal padding everywhere:3055 // Layout uses exactly 2 spaces of horizontal padding everywhere:
3049- // 2 spaces (openai) gpt-5.4 $0.000 (sub) 0.0%/400k ~/Sites/zut3056+ // 2 spaces gpt-5.4:medium $0.00 ctx0%/400k
3050 // matches the editor prompt's left inset so the bar lines up3057 // matches the editor prompt's left inset so the bar lines up
3051 // vertically with the conversation column.3058 // vertically with the conversation column.
3052 const pad = " " // 2 spaces3059 const pad = " " // 2 spaces
3053 3060
3054- left := fmt.Sprintf("(%s) %s", p.Provider, p.Model)3061+ left := p.Model
3055- reasoning := reasoningLevelLabel(p.Reasoning)3062+ if reasoning := reasoningLevelLabel(p.Reasoning); reasoning != "" {
3056- reasoningText := ""3063+ left += ":" + reasoning
3057- if reasoning != "" {
3058- reasoningText = "reasoning: " + reasoning
3059 }3064 }
3065+ modelColor := reasoningStatusColor(th, left)
3060 fastText := ""3066 fastText := ""
3061 if p.FastMode {3067 if p.FastMode {
3062 fastText = "fast mode"3068 fastText = "fast mode"
@@ -3066,9 +3072,6 @@ func StatusBar(p StatusBarParams) []string {
3066 if fastText != "" {3072 if fastText != "" {
3067 middleParts = append(middleParts, fastText)3073 middleParts = append(middleParts, fastText)
3068 }3074 }
3069- if reasoningText != "" {
3070- middleParts = append(middleParts, reasoningText)
3071- }
3072 if statsText != "" {3075 if statsText != "" {
3073 middleParts = append(middleParts, statsText)3076 middleParts = append(middleParts, statsText)
3074 }3077 }
@@ -3082,7 +3085,7 @@ func StatusBar(p StatusBarParams) []string {
3082 // those choices. The pad itself needs no color (it's spaces).3085 // those choices. The pad itself needs no color (it's spaces).
3083 leftBuilder.WriteString(pad + p.BusyPrefix)3086 leftBuilder.WriteString(pad + p.BusyPrefix)
3084 // Exactly one pad (2 spaces) between the busy segment and3087 // Exactly one pad (2 spaces) between the busy segment and
3085- // the provider/model block. The leading pad above covers3088+ // the model block. The leading pad above covers
3086 // the left indent.3089 // the left indent.
3087 leftBuilder.WriteString(pad)3090 leftBuilder.WriteString(pad)
3088 } else {3091 } else {
@@ -3092,11 +3095,10 @@ func StatusBar(p StatusBarParams) []string {
3092 // prefix there's no trailing separator to double-pad.3095 // prefix there's no trailing separator to double-pad.
3093 leftBuilder.WriteString(pad)3096 leftBuilder.WriteString(pad)
3094 }3097 }
3095- leftBuilder.WriteString(th.FGColor(th.Muted, left))3098+ leftBuilder.WriteString(th.FGColor(modelColor, left))
3096 if middle != "" {3099 if middle != "" {
3097 leftBuilder.WriteString(pad)3100 leftBuilder.WriteString(pad)
3098- // Highlight the opt-in max tier; other status information stays muted.3101+ leftBuilder.WriteString(th.FGColor(th.Muted, middle))
3099- leftBuilder.WriteString(th.FGColor(reasoningStatusColor(th, reasoningText), middle))
3100 }3102 }
3101 3103
3102 cwd := shortenHome(p.CWD)3104 cwd := shortenHome(p.CWD)
@@ -3122,16 +3124,16 @@ func StatusBar(p StatusBarParams) []string {
3122 // stats on their own rows. This mirrors the idle split below.3124 // stats on their own rows. This mirrors the idle split below.
3123 if p.Cols > 0 && p.BusyPrefix != "" && visibleWidth(primary) > p.Cols {3125 if p.Cols > 0 && p.BusyPrefix != "" && visibleWidth(primary) > p.Cols {
3124 busyLine := pad + p.BusyPrefix3126 busyLine := pad + p.BusyPrefix
3125- modelLine := pad + th.FGColor(th.Muted, left)3127+ modelLine := pad + th.FGColor(modelColor, left)
3126 lines := []string{busyLine}3128 lines := []string{busyLine}
3127- if middle != "" && visibleWidth(modelLine+pad+th.FGColor(th.Muted, middle)) > p.Cols {3129+ if visibleWidth(modelLine+pad+middle) > p.Cols {
3128- lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, stats, p.Cols)3130+ lines = appendWrappedStatusLines(lines, th, pad, left, fastText, stats, p.Cols)
3129 } else {3131 } else {
3130 var infoBuilder strings.Builder3132 var infoBuilder strings.Builder
3131 infoBuilder.WriteString(modelLine)3133 infoBuilder.WriteString(modelLine)
3132 if middle != "" {3134 if middle != "" {
3133 infoBuilder.WriteString(pad)3135 infoBuilder.WriteString(pad)
3134- infoBuilder.WriteString(th.FGColor(reasoningStatusColor(th, reasoningText), middle))3136+ infoBuilder.WriteString(th.FGColor(th.Muted, middle))
3135 }3137 }
3136 lines = append(lines, infoBuilder.String())3138 lines = append(lines, infoBuilder.String())
3137 }3139 }
@@ -3141,13 +3143,13 @@ func StatusBar(p StatusBarParams) []string {
3141 return lines3143 return lines
3142 }3144 }
3143 3145
3144- // Idle narrow split: keep provider/model on the first status line,3146+ // Idle narrow split: keep model/reasoning on the first status line,
3145 // move usage/cost/context stats to the next, then cwd below. This3147 // move usage/cost/context stats to the next, then cwd below. This
3146 // avoids the terminal's hard wrap cutting the stats or pushing cwd3148 // avoids the terminal's hard wrap cutting the stats or pushing cwd
3147 // into an awkward position on small widths.3149 // into an awkward position on small widths.
3148- if p.Cols > 0 && p.BusyPrefix == "" && middle != "" && visibleWidth(primary) > p.Cols {3150+ if p.Cols > 0 && p.BusyPrefix == "" && visibleWidth(primary) > p.Cols {
3149 var lines []string3151 var lines []string
3150- lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, stats, p.Cols)3152+ lines = appendWrappedStatusLines(lines, th, pad, left, fastText, stats, p.Cols)
3151 if cwd != "" {3153 if cwd != "" {
3152 lines = append(lines, pad+th.FGColor(th.Muted, cwd))3154 lines = append(lines, pad+th.FGColor(th.Muted, cwd))
3153 }3155 }
@@ -3159,42 +3161,19 @@ func StatusBar(p StatusBarParams) []string {
3159 }3161 }
3160 3162
3161 // Second line: indent with the same 2-space pad so the cwd lines3163 // Second line: indent with the same 2-space pad so the cwd lines
3162- // up under the "(provider)" column on line 1.3164+ // up under the model column on line 1.
3163 cwdRendered := pad + th.FGColor(th.Muted, cwd)3165 cwdRendered := pad + th.FGColor(th.Muted, cwd)
3164 return []string{primary, cwdRendered}3166 return []string{primary, cwdRendered}
3165 }3167 }
3166 3168
3167-func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText, reasoningText string, stats []string, cols int) []string {3169+func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText string, stats []string, cols int) []string {
3168- modelLine := pad + th.FGColor(th.Muted, modelText)3170+ modelLine := pad + th.FGColor(reasoningStatusColor(th, modelText), modelText)
3169- infoParts := make([]string, 0, 2)3171+ if fastText != "" && visibleWidth(modelLine+pad+fastText) <= cols {
3170- if fastText != "" {3172+ modelLine += pad + th.FGColor(th.Muted, fastText)
3171- infoParts = append(infoParts, fastText)3173+ } else if fastText != "" {
3172- }3174+ stats = append([]string{fastText}, stats...)
3173- if reasoningText != "" {
3174- infoParts = append(infoParts, reasoningText)
3175- }
3176- infoText := strings.Join(infoParts, pad)
3177- if infoText == "" {
3178- lines = append(lines, modelLine)
3179- return appendWrappedUsageStats(lines, th, pad, stats, cols)
3180- }
3181-
3182- modelInfoPlain := pad + modelText + pad + infoText
3183- infoColor := th.Muted
3184- if reasoningText != "" {
3185- infoColor = reasoningStatusColor(th, reasoningText)
3186- }
3187- if cols <= 0 || visibleWidth(modelInfoPlain) <= cols {
3188- lines = append(lines, pad+th.FGColor(infoColor, modelText+pad+infoText))
3189- } else {
3190- lines = append(lines, modelLine)
3191- if fastText != "" {
3192- lines = append(lines, pad+th.FGColor(th.Muted, fastText))
3193- }
3194- if reasoningText != "" {
3195- lines = append(lines, pad+th.FGColor(reasoningStatusColor(th, reasoningText), reasoningText))
3196- }
3197 }3175 }
3176+ lines = append(lines, truncateToWidth(modelLine, cols))
3198 return appendWrappedUsageStats(lines, th, pad, stats, cols)3177 return appendWrappedUsageStats(lines, th, pad, stats, cols)
3199 }3178 }
3200 3179
@@ -3227,7 +3206,7 @@ func appendWrappedUsageStats(lines []string, th Theme, pad string, stats []strin
3227 }3206 }
3228 3207
3229 func reasoningStatusColor(th Theme, reasoningText string) TerminalColor {3208 func reasoningStatusColor(th Theme, reasoningText string) TerminalColor {
3230- if strings.HasSuffix(reasoningText, ": max") {3209+ if strings.HasSuffix(reasoningText, ":max") {
3231 return th.ThinkingMax3210 return th.ThinkingMax
3232 }3211 }
3233 return th.Muted3212 return th.Muted

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close