diff --git a/internal/cli/run.go b/internal/cli/run.go index ff84ae7..3882b05 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -184,23 +184,24 @@ func runCombinedUsage(ctx context.Context, args []string, output io.Writer) erro result usageResult }{ {name: "ChatGPT", result: <-chatGPTResults}, - {name: "OpenCode Go", result: <-openCodeResults}, + {name: "OpenCode", result: <-openCodeResults}, } succeeded := 0 var failures []string - for index, provider := range results { - if index > 0 { - fmt.Fprintln(output) - } - fmt.Fprintln(output, provider.name) + providers := make([]usageTableProvider, 0, len(results)) + for _, provider := range results { + providers = append(providers, usageTableProvider{ + name: provider.name, + usage: provider.result.usage, + err: provider.result.err, + }) if provider.result.err != nil { - fmt.Fprintf(output, "ERROR: %s\n", provider.result.err) failures = append(failures, provider.name) continue } - printUsage(output, provider.result.usage) succeeded++ } + printUsageTableAt(output, providers, time.Now(), true) if succeeded == 0 { return fmt.Errorf("usage is unavailable for %s", strings.Join(failures, " and ")) } @@ -257,50 +258,84 @@ func printUsage(output io.Writer, usage api.ProviderUsage) { printUsageAt(output, usage, time.Now()) } +type usageTableProvider struct { + name string + usage api.ProviderUsage + err error +} + func printUsageAt(output io.Writer, usage api.ProviderUsage, now time.Time) { if len(usage.Accounts) == 0 { fmt.Fprintf(output, "No %s credentials are registered.\n", usage.Provider) return } - rows := [][]string{{"NAME", "PLAN", "LIMIT", "WINDOW", "USED", "REMAINING", "RESET", "PACE"}} + printUsageTableAt(output, []usageTableProvider{{usage: usage}}, now, false) +} + +func printUsageTableAt(output io.Writer, providers []usageTableProvider, now time.Time, showProvider bool) { + rows := [][]string{{"PROVIDER", "ACCOUNT", "PLAN", "LIMIT", "WINDOW", "USED", "REMAINING", "RESET", "PACE"}} + if !showProvider { + rows[0] = rows[0][1:] + rows[0][0] = "NAME" + } accountSeparators := map[int]bool{} - for _, account := range usage.Accounts { - if len(rows) > 1 && (account.Error != nil || len(account.Limits) > 0) { - accountSeparators[len(rows)] = true - } - plan := "-" - if account.Plan != nil && *account.Plan != "" { - plan = *account.Plan + for _, provider := range providers { + providerName := provider.name + if provider.err != nil { + if len(rows) > 1 { + accountSeparators[len(rows)] = true + } + rows = appendUsageRow(rows, showProvider, providerName, "-", "-", "-", "-", "-", "-", "ERROR: "+provider.err.Error(), "-") + continue } - if account.Error != nil { - rows = append(rows, []string{account.Name, plan, "-", "-", "-", "-", "ERROR: " + account.Error.Message, "-"}) + if len(provider.usage.Accounts) == 0 { + if len(rows) > 1 { + accountSeparators[len(rows)] = true + } + rows = appendUsageRow(rows, showProvider, providerName, "-", "-", "-", "-", "-", "-", "No accounts", "-") continue } - for index, limit := range account.Limits { - name := account.Name - rowPlan := plan - if index > 0 { - name = "" - rowPlan = "" + for _, account := range provider.usage.Accounts { + if len(rows) > 1 && (account.Error != nil || len(account.Limits) > 0) { + accountSeparators[len(rows)] = true + } + plan := "-" + if account.Plan != nil && *account.Plan != "" { + plan = *account.Plan + } + if account.Error != nil { + rows = appendUsageRow(rows, showProvider, providerName, account.Name, plan, "-", "-", "-", "-", "ERROR: "+account.Error.Message, "-") + providerName = "" + continue } - reset := "-" - if limit.ResetAt != nil { - reset = formatUsageReset(*limit.ResetAt, now) + for index, limit := range account.Limits { + name := account.Name + rowPlan := plan + if index > 0 { + name = "" + rowPlan = "" + } + reset := "-" + if limit.ResetAt != nil { + reset = formatUsageReset(*limit.ResetAt, now) + } + rows = appendUsageRow(rows, showProvider, providerName, name, rowPlan, limit.Name, limit.Window, fmt.Sprintf("%g%%", limit.UsedPercent), fmt.Sprintf("%g%%", limit.RemainingPercent), reset, usagePace(limit, now)) + providerName = "" } - pace := usagePace(limit, now) - rows = append(rows, []string{ - name, - rowPlan, - limit.Name, - limit.Window, - fmt.Sprintf("%g%%", limit.UsedPercent), - fmt.Sprintf("%g%%", limit.RemainingPercent), - reset, - pace, - }) - } - } - printTable(output, rows, map[int]bool{4: true, 5: true}, accountSeparators) + } + } + usedColumn := 4 + if showProvider { + usedColumn++ + } + printTable(output, rows, map[int]bool{usedColumn: true, usedColumn + 1: true}, accountSeparators) +} + +func appendUsageRow(rows [][]string, showProvider bool, values ...string) [][]string { + if !showProvider { + values = values[1:] + } + return append(rows, values) } func usagePace(limit api.UsageLimit, now time.Time) string { diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index f875401..3427689 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -53,7 +53,7 @@ func TestCombinedUsageShowsChatGPTAndOpenCodeGo(t *testing.T) { } fetchOpenCodeGoUsage = func(context.Context) (api.ProviderUsage, error) { return api.ProviderUsage{Provider: "opencode-go", Accounts: []api.UsageAccount{{ - Name: "OpenCode workspace", Limits: []api.UsageLimit{{Name: "rolling", Window: "5h", UsedPercent: 2, RemainingPercent: 98}}, + Name: "-", Limits: []api.UsageLimit{{Name: "rolling", Window: "5h", UsedPercent: 2, RemainingPercent: 98}}, }}}, nil } @@ -65,9 +65,7 @@ func TestCombinedUsageShowsChatGPTAndOpenCodeGo(t *testing.T) { t.Fatalf("ChatGPT options = %+v", chatGPTOptions) } text := output.String() - chatGPT := strings.Index(text, "ChatGPT\n") - openCode := strings.Index(text, "OpenCode Go\n") - if chatGPT < 0 || openCode < chatGPT || !strings.Contains(text, "person@example.com") || !strings.Contains(text, "OpenCode workspace") { + if strings.Count(text, "┌") != 1 || !strings.Contains(text, "│ PROVIDER │ ACCOUNT") || !strings.Contains(text, "ChatGPT") || !strings.Contains(text, "OpenCode") || !strings.Contains(text, "person@example.com") || strings.Contains(text, "OpenCode workspace") { t.Fatalf("output = %q", text) } } @@ -84,7 +82,7 @@ func TestCombinedUsageKeepsPartialResults(t *testing.T) { } fetchOpenCodeGoUsage = func(context.Context) (api.ProviderUsage, error) { return api.ProviderUsage{Provider: "opencode-go", Accounts: []api.UsageAccount{{ - Name: "OpenCode workspace", Limits: []api.UsageLimit{{Name: "weekly", Window: "7d"}}, + Name: "-", Limits: []api.UsageLimit{{Name: "weekly", Window: "7d"}}, }}}, nil } @@ -92,7 +90,7 @@ func TestCombinedUsageKeepsPartialResults(t *testing.T) { if err := Run(context.Background(), []string{"usage"}, &output, &bytes.Buffer{}, "test"); err != nil { t.Fatal(err) } - if !strings.Contains(output.String(), "ERROR: ChatGPT login unavailable") || !strings.Contains(output.String(), "OpenCode workspace") { + if !strings.Contains(output.String(), "ERROR: ChatGPT login unavailable") || !strings.Contains(output.String(), "OpenCode") { t.Fatalf("output = %q", output.String()) } } @@ -113,7 +111,7 @@ func TestCombinedUsageFailsOnlyWhenEveryProviderFails(t *testing.T) { var output bytes.Buffer err := Run(context.Background(), []string{"usage"}, &output, &bytes.Buffer{}, "test") - if err == nil || err.Error() != "usage is unavailable for ChatGPT and OpenCode Go" { + if err == nil || err.Error() != "usage is unavailable for ChatGPT and OpenCode" { t.Fatalf("error = %v", err) } } @@ -126,7 +124,7 @@ func TestOpenCodeGoUsageUsesTheLocalBrowserWithoutCirclesCredentials(t *testing. fetchOpenCodeGoUsage = func(context.Context) (api.ProviderUsage, error) { called = true return api.ProviderUsage{Provider: "opencode-go", Accounts: []api.UsageAccount{{ - ID: "wrk_EXAMPLE", Name: "OpenCode workspace", Plan: &plan, + ID: "wrk_EXAMPLE", Name: "-", Plan: &plan, Limits: []api.UsageLimit{{Name: "rolling", Window: "5h", UsedPercent: 2, RemainingPercent: 98}}, }}}, nil } @@ -136,7 +134,7 @@ func TestOpenCodeGoUsageUsesTheLocalBrowserWithoutCirclesCredentials(t *testing. if err := Run(context.Background(), []string{"opencode-go", "usage"}, &output, &bytes.Buffer{}, "test"); err != nil { t.Fatal(err) } - if !called || !strings.Contains(output.String(), "OpenCode workspace") || !strings.Contains(output.String(), "rolling") { + if !called || !strings.Contains(output.String(), "rolling") { t.Fatalf("called = %v, output = %q", called, output.String()) } } diff --git a/internal/opencodego/usage.go b/internal/opencodego/usage.go index 8687219..c80a056 100644 --- a/internal/opencodego/usage.go +++ b/internal/opencodego/usage.go @@ -120,9 +120,9 @@ func fetchFromSessions( break } - name := "OpenCode workspace" + name := "-" if len(workspaceIDs) > 1 { - name = fmt.Sprintf("OpenCode workspace %d", index+1) + name = fmt.Sprintf("%d", index+1) } plan := "Go" account := api.UsageAccount{ diff --git a/internal/opencodego/usage_test.go b/internal/opencodego/usage_test.go index e1932da..cc913cf 100644 --- a/internal/opencodego/usage_test.go +++ b/internal/opencodego/usage_test.go @@ -106,7 +106,7 @@ monthlyUsage:{status:"active",resetInSec:180,usagePercent:3}} if zenRequests != 2 || len(usage.Accounts) != 1 || len(usage.Accounts[0].Limits) != 3 { t.Fatalf("zen requests = %d, usage = %#v", zenRequests, usage) } - if usage.Provider != "opencode-go" || usage.Accounts[0].ID != "wrk_EXAMPLE" || usage.Accounts[0].Name != "OpenCode workspace" { + if usage.Provider != "opencode-go" || usage.Accounts[0].ID != "wrk_EXAMPLE" || usage.Accounts[0].Name != "-" { t.Fatalf("usage = %#v", usage) } }