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 Unverified
B5690EEEBB952194The 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: | |||
| 121 | 121 | ||
| 122 | 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 | 136 | ### Token refresh | |
| 125 | 137 | ||
| 126 | 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: | |
| @@ -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 refresh | 136 | ### 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 | |||
| 1732 | 1732 | resolved.MergeExtensionTools(extToolAdapter) | |
| 1733 | 1733 | installSchedulerTool(resolved.ToolRegistry) | |
| 1734 | 1734 | 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 | |
| 1736 | 1736 | } | |
| 1737 | 1737 | ||
| 1738 | 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 | 1757 | resolved.MergeExtensionTools(extToolAdapter) | |
| 1758 | 1758 | installSchedulerTool(resolved.ToolRegistry) | |
| 1759 | 1759 | 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 | |
| 1761 | 1761 | } | |
| 1762 | 1762 | ||
| 1763 | 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 | 1790 | resolved.MergeExtensionTools(extToolAdapter) | |
| 1791 | 1791 | installSchedulerTool(resolved.ToolRegistry) | |
| 1792 | 1792 | 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 | |
| 1794 | 1794 | } | |
| 1795 | 1795 | ||
| 1796 | 1796 | if r.HasCredential() { | |
| 1797 | - | ag = wireAgentExt(r.NewAgent()) | |
| 1797 | + | ag = wireAgentExt(r.newInteractiveAgent()) | |
| 1798 | 1798 | } | |
| 1799 | 1799 | ||
| 1800 | 1800 | // /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, nil | 1735 | + | 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, nil | 1760 | + | 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, nil | 1793 | + | 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 every | 1800 | // /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) { | |||
| 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 | 933 | func TestApplySessionResumeBindsRebuiltAgentToDurableSessionID(t *testing.T) { | |
| 897 | 934 | path := syntheticSession(t, "stored-provider", "stored-model", provider.Usage{}) | |
| 898 | 935 | 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 { | |||
| 28 | 28 | Theme tui.Theme | |
| 29 | 29 | Model string | |
| 30 | 30 | 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 | |
| 32 | 32 | BaseURL string | |
| 33 | 33 | Reasoning string | |
| 34 | 34 | SystemPrompt string | |
| @@ -706,6 +706,7 @@ type Interactive struct { | |||
| 706 | 706 | helpBlock []string // rendered above the chat when /help was typed | |
| 707 | 707 | sessionInfoBlocks []sessionInfoBlock | |
| 708 | 708 | cumUsage provider.Usage | |
| 709 | + | codexUsage codexUsageState | |
| 709 | 710 | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | |
| 710 | 711 | busy bool | |
| 711 | 712 | ctrlCExit bool | |
| @@ -1326,6 +1327,8 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1326 | 1327 | // transcript doesn't spin the cpu. | |
| 1327 | 1328 | tick := time.NewTicker(120 * time.Millisecond) | |
| 1328 | 1329 | defer tick.Stop() | |
| 1330 | + | i.refreshCodexUsage(ctx, time.Now()) | |
| 1331 | + | defer i.resetCodexUsage() | |
| 1329 | 1332 | ||
| 1330 | 1333 | // Redraw throttle: coalesce bursts of invalidate() calls so we paint | |
| 1331 | 1334 | // at most once every redrawMinInterval. Huge tool-result dumps can | |
| @@ -1567,6 +1570,7 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1567 | 1570 | requestRedraw() | |
| 1568 | 1571 | case <-tick.C: | |
| 1569 | 1572 | now := time.Now() | |
| 1573 | + | i.refreshCodexUsage(ctx, now) | |
| 1570 | 1574 | if !appearanceDeadline.IsZero() && !now.Before(appearanceDeadline) { | |
| 1571 | 1575 | appearanceParser.SetPendingColors(false) | |
| 1572 | 1576 | appearanceParser.SetPendingScheme(false) | |
| @@ -28,7 +28,7 @@ type InteractiveConfig struct { | |||||
| 28 | Theme tui.Theme | 28 | Theme tui.Theme | ||
| 29 | Model string | 29 | Model string | ||
| 30 | Provider string | 30 | 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 |
| 32 | BaseURL string | 32 | BaseURL string | ||
| 33 | Reasoning string | 33 | Reasoning string | ||
| 34 | SystemPrompt string | 34 | SystemPrompt string | ||
| @@ -706,6 +706,7 @@ type Interactive struct { | |||||
| 706 | helpBlock []string // rendered above the chat when /help was typed | 706 | helpBlock []string // rendered above the chat when /help was typed | ||
| 707 | sessionInfoBlocks []sessionInfoBlock | 707 | sessionInfoBlocks []sessionInfoBlock | ||
| 708 | cumUsage provider.Usage | 708 | cumUsage provider.Usage | ||
| 709 | + | codexUsage codexUsageState | |||
| 709 | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | 710 | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | ||
| 710 | busy bool | 711 | busy bool | ||
| 711 | ctrlCExit bool | 712 | 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 paint | 1333 | // Redraw throttle: coalesce bursts of invalidate() calls so we paint | ||
| 1331 | // at most once every redrawMinInterval. Huge tool-result dumps can | 1334 | // 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 | |||
| 242 | 242 | return oldTools, true | |
| 243 | 243 | } | |
| 244 | 244 | func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) { | |
| 245 | + | // This runs inside the replacement commit, never during candidate builds. | |
| 246 | + | i.resetCodexUsageLocked() | |
| 245 | 247 | if ag == nil { | |
| 246 | 248 | i.setWebSearchAvailable(false) | |
| 247 | 249 | return | |
| @@ -242,6 +242,8 @@ func (i *Interactive) applyAgentPromptConfig(ag *core.Agent, system string, tool | |||||
| 242 | return oldTools, true | 242 | 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 | return | 249 | return | ||
M packages/agent/modes/interactive_rendering.go +1 -0
| @@ -682,6 +682,7 @@ func (i *Interactive) redraw() { | |||
| 682 | 682 | GoalStatus: goalStatus, | |
| 683 | 683 | Usage: i.cumUsage, | |
| 684 | 684 | Subscription: i.cfg.AuthMethod == "oauth", | |
| 685 | + | WeeklyUsage: i.codexWeeklyLabelLocked(time.Now()), | |
| 685 | 686 | ContextUsed: i.lastCtxInput, | |
| 686 | 687 | ContextMax: ctxMax, | |
| 687 | 688 | 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 @@ | |||
| 1 | 1 | package tui | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 4 | 5 | "strings" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| 7 | 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 | 31 | func TestStatusBarShowsCacheHitRatio(t *testing.T) { | |
| 11 | 32 | lines := StatusBar(StatusBarParams{ | |
| 12 | 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 | 36 | ContextUsed: 45_152, ContextMax: 272_000, | |
| 15 | 37 | }) | |
| 16 | 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 | 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) { | |
| 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 | + | }) | |
| 47 | 81 | } | |
| 48 | 82 | } | |
| 49 | 83 | } | |
| 50 | 84 | ||
| 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 | + | } | |
| 63 | 93 | } | |
| 94 | + | } | |
| 64 | 95 | ||
| 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) | |
| 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 | 105 | func TestStatusBarAlwaysTwoLines(t *testing.T) { | |
| 80 | - | // Wide terminal that would previously combine into one line. | |
| 81 | 106 | 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, | |
| 95 | 110 | }) | |
| 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) | |
| 108 | 113 | } | |
| 109 | 114 | } | |
| 110 | 115 | ||
| 111 | - | // TestStatusBarNoCWD verifies an empty cwd stays single-line. | |
| 112 | 116 | 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}) | |
| 120 | 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 | 123 | 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) | |
| 140 | 127 | } | |
| 141 | 128 | } | |
| 142 | 129 | ||
| 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 | + | } | |
| 168 | 139 | } | |
| 169 | 140 | } | |
| 170 | 141 | ||
| 171 | 142 | func TestStatusBarUsesReasoningMaxThemeColor(t *testing.T) { | |
| 172 | 143 | th := Dark | |
| 173 | 144 | 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 | + | } | |
| 249 | 150 | } | |
| 250 | 151 | } | |
| 251 | 152 | ||
| 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 | + | } | |
| 283 | 163 | } | |
| 284 | 164 | } | |
| 285 | 165 | ||
| 286 | 166 | 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) | |
| 301 | 170 | } | |
| 302 | 171 | } | |
| @@ -1,20 +1,42 @@ | |||||
| 1 | package tui | 1 | 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 = 30 | 56 | + | 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 = 35 | 96 | + | 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 | - | return | 100 | + | 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 := Dark | 143 | 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 ( | |||
| 10 | 10 | type UsageStatsParams struct { | |
| 11 | 11 | Usage provider.Usage | |
| 12 | 12 | Subscription bool | |
| 13 | + | Compact bool // main status bar; detailed surfaces retain billing labels and precision | |
| 13 | 14 | } | |
| 14 | 15 | ||
| 15 | 16 | // UsageStatsParts returns compact, provider-neutral token and cost labels. | |
| @@ -22,17 +23,29 @@ func UsageStatsParts(p UsageStatsParams) []string { | |||
| 22 | 23 | parts = append(parts, fmt.Sprintf("↓%s", formatTokens(p.Usage.OutputTokens))) | |
| 23 | 24 | } | |
| 24 | 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 | 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 | 39 | if p.Usage.CacheWriteTokens > 0 { | |
| 31 | 40 | parts = append(parts, fmt.Sprintf("W%s", formatTokens(p.Usage.CacheWriteTokens))) | |
| 32 | 41 | } | |
| 33 | 42 | 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 { | |
| 36 | 49 | cost += " (sub)" | |
| 37 | 50 | } | |
| 38 | 51 | parts = append(parts, cost) | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | type UsageStatsParams struct { | 10 | type UsageStatsParams struct { | ||
| 11 | Usage provider.Usage | 11 | Usage provider.Usage | ||
| 12 | Subscription bool | 12 | 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 { | |||
| 2992 | 2992 | Usage provider.Usage | |
| 2993 | 2993 | // Subscription is true when the credential is an OAuth token (claude | |
| 2994 | 2994 | // 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. | |
| 2997 | 2996 | 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 | 3002 | // Last turn's input+cache tokens (approximates current live context). | |
| 3000 | 3003 | ContextUsed int | |
| 3001 | 3004 | ContextMax int // model's context window; 0 disables the percentage | |
| @@ -3010,19 +3013,16 @@ type StatusBarParams struct { | |||
| 3010 | 3013 | // glance that dms are being mirrored into this session. | |
| 3011 | 3014 | Telegram bool | |
| 3012 | 3015 | ||
| 3013 | - | Cols int // terminal width; drives right-alignment of cwd | |
| 3016 | + | Cols int // terminal width; drives status wrapping | |
| 3014 | 3017 | } | |
| 3015 | 3018 | ||
| 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. | |
| 3022 | 3022 | // | |
| 3023 | 3023 | // Layout: | |
| 3024 | 3024 | // | |
| 3025 | - | // <busyPrefix> (provider) model stats <- line 1 | |
| 3025 | + | // <busyPrefix> model:reasoning stats <- line 1 | |
| 3026 | 3026 | // cwd <- line 2 (2-space indent) | |
| 3027 | 3027 | // | |
| 3028 | 3028 | // The old "ctrl+c exit - /help" / "esc cancel" hint is gone entirely. | |
| @@ -3031,7 +3031,10 @@ type StatusBarParams struct { | |||
| 3031 | 3031 | func StatusBar(p StatusBarParams) []string { | |
| 3032 | 3032 | 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 | 3038 | if p.GoalStatus != "" { | |
| 3036 | 3039 | stats = append(stats, "goal:"+p.GoalStatus) | |
| 3037 | 3040 | } | |
| @@ -3039,6 +3042,10 @@ func StatusBar(p StatusBarParams) []string { | |||
| 3039 | 3042 | // Context %. Color-coded: yellow >70, red >90. | |
| 3040 | 3043 | ctx, ctxColor := contextUsage(th, p.ContextUsed, p.ContextMax) | |
| 3041 | 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 | 3049 | if p.AutoCompacting { | |
| 3043 | 3050 | ctx += " (auto)" | |
| 3044 | 3051 | } | |
| @@ -3046,17 +3053,16 @@ func StatusBar(p StatusBarParams) []string { | |||
| 3046 | 3053 | } | |
| 3047 | 3054 | ||
| 3048 | 3055 | // 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 | |
| 3050 | 3057 | // matches the editor prompt's left inset so the bar lines up | |
| 3051 | 3058 | // vertically with the conversation column. | |
| 3052 | 3059 | const pad = " " // 2 spaces | |
| 3053 | 3060 | ||
| 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 | |
| 3059 | 3064 | } | |
| 3065 | + | modelColor := reasoningStatusColor(th, left) | |
| 3060 | 3066 | fastText := "" | |
| 3061 | 3067 | if p.FastMode { | |
| 3062 | 3068 | fastText = "fast mode" | |
| @@ -3066,9 +3072,6 @@ func StatusBar(p StatusBarParams) []string { | |||
| 3066 | 3072 | if fastText != "" { | |
| 3067 | 3073 | middleParts = append(middleParts, fastText) | |
| 3068 | 3074 | } | |
| 3069 | - | if reasoningText != "" { | |
| 3070 | - | middleParts = append(middleParts, reasoningText) | |
| 3071 | - | } | |
| 3072 | 3075 | if statsText != "" { | |
| 3073 | 3076 | middleParts = append(middleParts, statsText) | |
| 3074 | 3077 | } | |
| @@ -3082,7 +3085,7 @@ func StatusBar(p StatusBarParams) []string { | |||
| 3082 | 3085 | // those choices. The pad itself needs no color (it's spaces). | |
| 3083 | 3086 | leftBuilder.WriteString(pad + p.BusyPrefix) | |
| 3084 | 3087 | // 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 | |
| 3086 | 3089 | // the left indent. | |
| 3087 | 3090 | leftBuilder.WriteString(pad) | |
| 3088 | 3091 | } else { | |
| @@ -3092,11 +3095,10 @@ func StatusBar(p StatusBarParams) []string { | |||
| 3092 | 3095 | // prefix there's no trailing separator to double-pad. | |
| 3093 | 3096 | leftBuilder.WriteString(pad) | |
| 3094 | 3097 | } | |
| 3095 | - | leftBuilder.WriteString(th.FGColor(th.Muted, left)) | |
| 3098 | + | leftBuilder.WriteString(th.FGColor(modelColor, left)) | |
| 3096 | 3099 | if middle != "" { | |
| 3097 | 3100 | 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)) | |
| 3100 | 3102 | } | |
| 3101 | 3103 | ||
| 3102 | 3104 | cwd := shortenHome(p.CWD) | |
| @@ -3122,16 +3124,16 @@ func StatusBar(p StatusBarParams) []string { | |||
| 3122 | 3124 | // stats on their own rows. This mirrors the idle split below. | |
| 3123 | 3125 | if p.Cols > 0 && p.BusyPrefix != "" && visibleWidth(primary) > p.Cols { | |
| 3124 | 3126 | busyLine := pad + p.BusyPrefix | |
| 3125 | - | modelLine := pad + th.FGColor(th.Muted, left) | |
| 3127 | + | modelLine := pad + th.FGColor(modelColor, left) | |
| 3126 | 3128 | 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) | |
| 3129 | 3131 | } else { | |
| 3130 | 3132 | var infoBuilder strings.Builder | |
| 3131 | 3133 | infoBuilder.WriteString(modelLine) | |
| 3132 | 3134 | if middle != "" { | |
| 3133 | 3135 | infoBuilder.WriteString(pad) | |
| 3134 | - | infoBuilder.WriteString(th.FGColor(reasoningStatusColor(th, reasoningText), middle)) | |
| 3136 | + | infoBuilder.WriteString(th.FGColor(th.Muted, middle)) | |
| 3135 | 3137 | } | |
| 3136 | 3138 | lines = append(lines, infoBuilder.String()) | |
| 3137 | 3139 | } | |
| @@ -3141,13 +3143,13 @@ func StatusBar(p StatusBarParams) []string { | |||
| 3141 | 3143 | 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 | 3147 | // move usage/cost/context stats to the next, then cwd below. This | |
| 3146 | 3148 | // avoids the terminal's hard wrap cutting the stats or pushing cwd | |
| 3147 | 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 | 3151 | 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 | 3153 | if cwd != "" { | |
| 3152 | 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 | 3163 | // 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 | 3165 | cwdRendered := pad + th.FGColor(th.Muted, cwd) | |
| 3164 | 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 { | |
| 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...) | |
| 3197 | 3175 | } | |
| 3176 | + | lines = append(lines, truncateToWidth(modelLine, cols)) | |
| 3198 | 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 | 3208 | func reasoningStatusColor(th Theme, reasoningText string) TerminalColor { | |
| 3230 | - | if strings.HasSuffix(reasoningText, ": max") { | |
| 3209 | + | if strings.HasSuffix(reasoningText, ":max") { | |
| 3231 | 3210 | return th.ThinkingMax | |
| 3232 | 3211 | } | |
| 3233 | 3212 | return th.Muted | |
| @@ -2992,10 +2992,13 @@ type StatusBarParams struct { | |||||
| 2992 | Usage provider.Usage | 2992 | Usage provider.Usage | ||
| 2993 | // Subscription is true when the credential is an OAuth token (claude | 2993 | // Subscription is true when the credential is an OAuth token (claude | ||
| 2994 | // pro/max, chatgpt plus/pro) rather than a paid api key. We still | 2994 | // 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 | 2995 | + | // compute an API-equivalent cost for visibility, not an actual charge. |
| 2996 | - | // knows no real money moved. | |||
| 2997 | Subscription bool | 2996 | 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 int | 3003 | ContextUsed int | ||
| 3001 | ContextMax int // model's context window; 0 disables the percentage | 3004 | 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 bool | 3014 | Telegram bool | ||
| 3012 | 3015 | ||||
| 3013 | - | Cols int // terminal width; drives right-alignment of cwd | 3016 | + | Cols int // terminal width; drives status wrapping |
| 3014 | } | 3017 | } | ||
| 3015 | 3018 | ||||
| 3016 | - | // StatusBar builds the status shown above the editor. Always returns | 3019 | + | // 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, the | 3020 | + | // 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. This | 3021 | + | // 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 1 | 3025 | + | // <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.Theme | 3032 | 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/zut | 3056 | + | // 2 spaces gpt-5.4:medium $0.00 ctx0%/400k |
| 3050 | // matches the editor prompt's left inset so the bar lines up | 3057 | // 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 spaces | 3059 | 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 and | 3087 | // 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 |
| 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.BusyPrefix | 3126 | 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.Builder | 3132 | 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 lines | 3143 | 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. This | 3147 | // move usage/cost/context stats to the next, then cwd below. This | ||
| 3146 | // avoids the terminal's hard wrap cutting the stats or pushing cwd | 3148 | // 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 []string | 3151 | 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 lines | 3163 | // 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.ThinkingMax | 3210 | return th.ThinkingMax | ||
| 3232 | } | 3211 | } | ||
| 3233 | return th.Muted | 3212 | return th.Muted | ||