浏览代码

refactor(render): drop month progress row, show monthly cap as one-liner

The API doesn't populate used_* fields on quota_monthly, so the third
progress bar was always empty with — placeholders — looked like missing
data. Replace with a compact "Monthly cap: 34560 flows · $1134.33" line
between "Tokens consumed" and "Next reset".

Covered by the existing snapshot test plus a new negative assertion that
month/n/a/em-dash remnants don't leak into the bar region. JSON output
unchanged (raw API response still passes through).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
kotoyuuko 3 周之前
父节点
当前提交
d0f05ffcbd

+ 1 - 1
README.md

@@ -78,9 +78,9 @@ ZenMux Subscription — Ultra plan ($200/mo) · healthy · $0.03283/flow
 
   5 hour  [██░░░░░░░░░░░░░░░░░░░░░░░░░░░░]    7.15%   57.2 / 800 flows      $1.88 / $26.26
   7 day   [██░░░░░░░░░░░░░░░░░░░░░░░░░░░░]    6.73%   416.11 / 6182 flows   $13.66 / $202.95
-  month   [░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░]    n/a     — / 34560 flows       — / $1134.33
 
   Tokens consumed (estimated USD value): $13.66
+  Monthly cap: 34560 flows · $1134.33
   Next reset: 5h → 2026-04-22 22:05  ·  7d → 2026-04-28 08:00
 
 ━━━ work ━━━

+ 15 - 28
internal/render/render.go

@@ -17,7 +17,6 @@ const (
 	barWidth  = 30
 	barFilled = "█"
 	barEmpty  = "░"
-	emDash    = "—"
 )
 
 // AccountResult bundles a single account's fetch outcome for RenderAll.
@@ -79,9 +78,9 @@ func setGlobalColor(useColor bool) {
 	color.NoColor = !useColor
 }
 
-// RenderAccount writes the header + three quota rows + token summary for a
-// single account. Caller must have already called setGlobalColor via RenderAll,
-// or set color.NoColor directly.
+// RenderAccount writes the header + two quota rows + token summary + monthly
+// cap line for a single account. Caller must have already called
+// setGlobalColor via RenderAll, or set color.NoColor directly.
 func RenderAccount(w io.Writer, name string, resp *api.Response, useColor bool) {
 	writeHeader(w, name)
 	writePlanLine(w, resp)
@@ -89,16 +88,11 @@ func RenderAccount(w io.Writer, name string, resp *api.Response, useColor bool)
 
 	d := resp.Data
 	rows := []struct {
-		label   string
-		window  api.QuotaWindow
-		monthly bool
+		label  string
+		window api.QuotaWindow
 	}{
-		{"5 hour", d.Quota5Hour, false},
-		{"7 day", d.Quota7Day, false},
-		{"month", api.QuotaWindow{
-			MaxFlows:    d.QuotaMonthly.MaxFlows,
-			MaxValueUSD: d.QuotaMonthly.MaxValueUSD,
-		}, true},
+		{"5 hour", d.Quota5Hour},
+		{"7 day", d.Quota7Day},
 	}
 
 	// Pre-format each row's segments so we can align by max width.
@@ -109,19 +103,11 @@ func RenderAccount(w io.Writer, name string, resp *api.Response, useColor bool)
 	fmts := make([]formatted, len(rows))
 	for i, r := range rows {
 		f := formatted{label: r.label}
-		if r.monthly {
-			f.bar = ProgressBar(0, barWidth) // no used data available
-			f.pct = "  n/a  "
-			f.pctAttr = color.FgWhite
-			f.flows = fmt.Sprintf("%s / %s flows", emDash, fmtFlow(r.window.MaxFlows))
-			f.dollars = fmt.Sprintf("%s / %s", emDash, fmtUSD(r.window.MaxValueUSD))
-		} else {
-			f.bar = ProgressBar(r.window.UsagePercentage, barWidth)
-			f.pct = fmt.Sprintf("%6.2f%%", r.window.UsagePercentage*100)
-			f.pctAttr = BandColor(r.window.UsagePercentage)
-			f.flows = fmt.Sprintf("%s / %s flows", fmtFlow(r.window.UsedFlows), fmtFlow(r.window.MaxFlows))
-			f.dollars = fmt.Sprintf("%s / %s", fmtUSD(r.window.UsedValueUSD), fmtUSD(r.window.MaxValueUSD))
-		}
+		f.bar = ProgressBar(r.window.UsagePercentage, barWidth)
+		f.pct = fmt.Sprintf("%6.2f%%", r.window.UsagePercentage*100)
+		f.pctAttr = BandColor(r.window.UsagePercentage)
+		f.flows = fmt.Sprintf("%s / %s flows", fmtFlow(r.window.UsedFlows), fmtFlow(r.window.MaxFlows))
+		f.dollars = fmt.Sprintf("%s / %s", fmtUSD(r.window.UsedValueUSD), fmtUSD(r.window.MaxValueUSD))
 		fmts[i] = f
 	}
 	flowsW, dollarsW := 0, 0
@@ -142,8 +128,9 @@ func RenderAccount(w io.Writer, name string, resp *api.Response, useColor bool)
 	}
 
 	fmt.Fprintln(w)
-	tokens := fmtUSD(d.Quota7Day.UsedValueUSD)
-	fmt.Fprintf(w, "  Tokens consumed (estimated USD value): %s\n", tokens)
+	fmt.Fprintf(w, "  Tokens consumed (estimated USD value): %s\n", fmtUSD(d.Quota7Day.UsedValueUSD))
+	fmt.Fprintf(w, "  Monthly cap: %s flows · %s\n",
+		fmtFlow(d.QuotaMonthly.MaxFlows), fmtUSD(d.QuotaMonthly.MaxValueUSD))
 
 	resets := resetsLine(d.Quota5Hour.ResetsAt, d.Quota7Day.ResetsAt)
 	if resets != "" {

+ 12 - 4
internal/render/render_test.go

@@ -92,22 +92,30 @@ func TestRenderAccount_Snapshot(t *testing.T) {
 		"$0.03283/flow",
 		"5 hour",
 		"7 day",
-		"month",
 		"7.15%",
 		"6.73%",
-		" n/a",
 		"57.2 / 800 flows",
 		"416.11 / 6182 flows",
-		"— / 34560 flows",
 		"$1.88 / $26.26",
-		"— / $1134.33",
 		"Tokens consumed (estimated USD value): $13.66",
+		"Monthly cap: 34560 flows · $1134.33",
 	}
 	for _, w := range wants {
 		if !strings.Contains(got, w) {
 			t.Errorf("output missing %q\n---\n%s\n---", w, got)
 		}
 	}
+
+	// The old monthly row must be gone from the bar region.
+	barRegion := got
+	if idx := strings.Index(got, "Tokens consumed"); idx >= 0 {
+		barRegion = got[:idx]
+	}
+	for _, forbidden := range []string{"month ", " n/a", "— / ", "— /"} {
+		if strings.Contains(barRegion, forbidden) {
+			t.Errorf("bar region still contains %q\n---\n%s\n---", forbidden, barRegion)
+		}
+	}
 }
 
 func TestRenderAll_MultiAccountWithError(t *testing.T) {

+ 2 - 0
openspec/changes/archive/2026-04-22-simplify-monthly-display/.openspec.yaml

@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-04-22

+ 57 - 0
openspec/changes/archive/2026-04-22-simplify-monthly-display/design.md

@@ -0,0 +1,57 @@
+## Context
+
+The existing renderer builds a fixed three-row table for the quota windows (5-hour / 7-day / month) inside `RenderAccount`. Each row includes a progress bar, percentage, and used-vs-max columns. For the monthly row, `used_*` fields are synthesized as `—` placeholders and the percentage prints as ` n/a ` because the API does not return them — only `max_flows` and `max_value_usd`. This makes the monthly row visually inconsistent with its peers and implies data is missing when in fact the API is behaving as documented.
+
+The user's ask is to move the monthly values out of the progress-bar block into a simpler one-line summary below the existing "Tokens consumed" line.
+
+## Goals / Non-Goals
+
+**Goals:**
+- Remove visual noise: no more empty bar or ` n/a ` / `—` placeholders.
+- Keep monthly limits visible — users still want to see "34560 flows / $1134.33 per month" for context.
+- Preserve per-account layout (header → quota rows → summary lines) so multi-account output still reads top-to-bottom naturally.
+- Touch the minimum number of files: renderer + tests + README.
+
+**Non-Goals:**
+- Change JSON output shape. The raw API response is unchanged; piped consumers keep getting `quota_monthly` verbatim.
+- Hide the monthly cap entirely. The user explicitly asked to display it, just in a simpler location.
+- Restyle the existing two bars, header, or reset line.
+
+## Decisions
+
+### 1. Placement of the monthly cap line
+**Decision:** Emit it immediately after the "Tokens consumed (estimated USD value): $X.XX" line and before the "Next reset" line. Format:
+
+```
+  Tokens consumed (estimated USD value): $13.66
+  Monthly cap: 34560 flows · $1134.33
+  Next reset: 5h → 2026-04-22 14:05  ·  7d → 2026-04-28 00:00
+```
+
+**Why:** The three summary lines now read as a tight stack: what you used (usage value), what your cap is (cap), and when the windows reset. All three are "read-only facts about this account" and belong together.
+**Alternatives considered:**
+- Put it on the plan/header line (e.g. `Ultra plan ($200/mo) · healthy · $0.03283/flow · 34560 flows/mo cap`). Rejected — the header is already the busiest line; adding another `·`-joined field pushes it past terminal width on small monitors.
+- Put it below "Next reset". Rejected — feels like a footer afterthought.
+- Drop it entirely. Rejected — user explicitly asked to keep it visible.
+
+### 2. Formatting convention
+**Decision:** `  Monthly cap: <fmtFlow(max_flows)> flows · $<%.2f>(max_value_usd)`. Use the existing `fmtFlow` and `fmtUSD` helpers from `render.go` so integers stay integers (`34560` not `34560.00`) and the dollar figure always has two decimals.
+**Why:** Consistent with how the other rows format flows and USD.
+
+### 3. Renderer code change
+**Decision:** Delete the third entry (`"month"`) from the `rows` slice inside `RenderAccount`. The monthly-branch (`r.monthly`) code paths in the same function then become dead and can be removed. After the token-consumed line, add one `Fprintf` using `d.QuotaMonthly.MaxFlows` and `d.QuotaMonthly.MaxValueUSD`.
+**Why:** Keeps the existing two-row column-alignment logic intact; we're only removing a row, not rewriting the layout algorithm.
+**Alternatives considered:** Parameterize the rows by a `showMonthly bool`. Rejected — overkill for a change that's removing the concept entirely.
+
+### 4. Tests
+**Decision:** Adjust the existing `TestRenderAccount_Snapshot` assertions — drop the three checks that reference the monthly row (`" n/a"`, `"— / 34560 flows"`, `"— / $1134.33"`), add one that matches `"Monthly cap: 34560 flows · $1134.33"`. The `TestRenderAll_MultiAccountWithError` and color-band tests are unaffected.
+
+## Risks / Trade-offs
+
+- **Users who read the monthly row as a "progress indicator"** → they'll no longer see an empty bar. *Mitigation:* the cap line is labeled explicitly; anyone who wants a progress view of monthly usage can look at the 7-day window, which is the closest proxy the API provides.
+- **Column-width regressions** → removing a row shortens the widest flows/dollars column, possibly changing alignment for the remaining two rows. *Mitigation:* existing code computes max width across the rows slice, so it naturally re-aligns; no special handling needed.
+- **README drift** → the ASCII sample in README.md also renders the old three-row layout. *Mitigation:* update it in the same change.
+
+## Migration Plan
+
+N/A — runtime behavior change only. No config migration, no flag change, no exit-code change.

+ 25 - 0
openspec/changes/archive/2026-04-22-simplify-monthly-display/proposal.md

@@ -0,0 +1,25 @@
+## Why
+
+The current renderer shows three quota rows (5-hour, 7-day, month), but the monthly window is an awkward fit: the API only returns `max_flows` and `max_value_usd` for it — no `used_*` fields — so the monthly progress bar is always empty and the used columns show `—`. Visually this looks like a bug or missing data. Since the monthly row is really just a cap/limit rather than a live-usage window, it belongs below the usage bars as a compact one-liner, not as a peer row next to the two real progress windows.
+
+## What Changes
+
+- Drop the `month` row from the three-progress-bar block. The renderer now shows only the `5 hour` and `7 day` rows with bars.
+- Add a compact "Monthly cap" summary line below the "Tokens consumed" line, formatted as `Monthly cap: <max_flows> flows · $<max_value_usd>` (e.g. `Monthly cap: 34560 flows · $1134.33`).
+- Update the human-mode snapshot tests and the README ASCII sample accordingly.
+- No change to JSON output — `quota_monthly` is still passed through in the API response verbatim.
+
+## Capabilities
+
+### New Capabilities
+<!-- None — this is a behavior change to an existing capability. -->
+
+### Modified Capabilities
+- `subscription-usage`: the "Render three quota windows per account" requirement becomes "Render two quota windows per account"; the "Display token USD value consumed per account" requirement's prose is adjusted from "three quota rows" to "quota rows"; a new "Display monthly cap" requirement is added.
+
+## Impact
+
+- `internal/render/render.go` — remove the `month` entry from the progress-bar row builder; add a new line emission after the tokens-consumed line.
+- `internal/render/render_test.go` — drop the `— / 34560 flows` and ` n/a` assertions; add an assertion for the new `Monthly cap:` line.
+- `README.md` — update the ASCII sample to reflect two progress bars plus the monthly cap line.
+- No API, config, or CLI-flag changes; no effect on exit codes or JSON mode.

+ 47 - 0
openspec/changes/archive/2026-04-22-simplify-monthly-display/specs/subscription-usage/spec.md

@@ -0,0 +1,47 @@
+## MODIFIED Requirements
+
+### Requirement: Render two quota windows per account
+
+In human output mode the CLI SHALL render, for each resolved account, one labeled row per rolling window — `5 hour` and `7 day`. Each row MUST include a fixed-width progress bar, the usage percentage to two decimals, the used-vs-max flows, and the used-vs-max USD value. Bars MUST be color-coded by usage band: green below 60%, yellow from 60% through 85%, red above 85%. The monthly quota MUST NOT be rendered as a third progress-bar row; it is emitted separately by the "Display monthly cap" requirement.
+
+#### Scenario: Healthy 5-hour window
+- **WHEN** the API returns `quota_5_hour.usage_percentage = 0.0715`, `used_flows = 57.2`, `max_flows = 800`, `used_value_usd = 1.88`, `max_value_usd = 26.26` for an account
+- **THEN** that account's block contains a row labeled `5 hour`, a green-colored bar roughly 7% filled, `7.15%`, `57.2 / 800 flows`, and `$1.88 / $26.26`
+
+#### Scenario: No monthly progress row rendered
+- **WHEN** an account is rendered in human mode
+- **THEN** the output MUST NOT contain a row labeled `month` inside the progress-bar block, and MUST NOT contain an em-dash (`—`) placeholder or `n/a` percentage inside the bar rows
+
+#### Scenario: Color disabled via flag
+- **WHEN** the user runs `zenmux-usage --no-color`
+- **THEN** no ANSI color escape sequences appear in the output
+
+#### Scenario: Non-TTY output is automatically plain
+- **WHEN** stdout is piped or redirected to a file
+- **THEN** the CLI omits ANSI color escape sequences regardless of the `--no-color` flag
+
+### Requirement: Display token USD value consumed per account
+
+Below each account's quota rows the CLI SHALL print a summary line labeled "Tokens consumed (estimated USD value)" showing the `used_value_usd` from the 7-day window for that account, formatted as USD to two decimals.
+
+#### Scenario: 7-day used value present
+- **WHEN** an account's `quota_7_day.used_value_usd = 13.66`
+- **THEN** that account's summary line reads `Tokens consumed (estimated USD value): $13.66`
+
+## ADDED Requirements
+
+### Requirement: Display monthly cap
+
+Below the "Tokens consumed" summary line and above any "Next reset" line the CLI SHALL, in human output mode, emit a one-line summary of the monthly quota cap using `quota_monthly.max_flows` and `quota_monthly.max_value_usd`. The line MUST be formatted as `Monthly cap: <max_flows> flows · $<max_value_usd>` with `max_flows` printed without trailing zeros and `max_value_usd` formatted as USD to two decimals.
+
+#### Scenario: Monthly cap present
+- **WHEN** an account returns `quota_monthly.max_flows = 34560` and `quota_monthly.max_value_usd = 1134.33`
+- **THEN** the output contains a line `Monthly cap: 34560 flows · $1134.33`
+
+#### Scenario: Cap line placement
+- **WHEN** the human-mode block is rendered for an account
+- **THEN** the "Monthly cap:" line appears after the "Tokens consumed (estimated USD value):" line and before the "Next reset:" line (when a reset line is emitted)
+
+#### Scenario: JSON mode unchanged
+- **WHEN** the user runs `zenmux-usage --json`
+- **THEN** no "Monthly cap" line is injected into the JSON output; the `quota_monthly` field from the API response is the only monthly representation

+ 21 - 0
openspec/changes/archive/2026-04-22-simplify-monthly-display/tasks.md

@@ -0,0 +1,21 @@
+## 1. Renderer
+
+- [x] 1.1 In `internal/render/render.go`, remove the third entry (the `"month"` row) from the `rows` slice inside `RenderAccount`
+- [x] 1.2 Remove the `monthly bool` field on the row struct and the `if r.monthly { ... }` branch; both become dead after the row is gone
+- [x] 1.3 Immediately after the existing `Tokens consumed (estimated USD value): ...` line, emit `  Monthly cap: <fmtFlow(MaxFlows)> flows · $<fmtUSD raw value>` using `d.QuotaMonthly.MaxFlows` and `d.QuotaMonthly.MaxValueUSD`
+- [x] 1.4 Verify the two-row layout still right-aligns cleanly by running the in-module smoke path (no code change expected; width calc already handles variable row count)
+
+## 2. Tests
+
+- [x] 2.1 Update `TestRenderAccount_Snapshot` in `internal/render/render_test.go`: drop the three expected substrings referencing the old monthly row (`" n/a"`, `"— / 34560 flows"`, `"— / $1134.33"`)
+- [x] 2.2 Add an expected substring `"Monthly cap: 34560 flows · $1134.33"` to `TestRenderAccount_Snapshot`
+- [x] 2.3 Add a negative assertion: the rendered output MUST NOT contain the substring `"month"` inside the bar region (i.e. before the "Tokens consumed" line)
+- [x] 2.4 Run `go test ./...` and confirm all packages pass
+
+## 3. Documentation
+
+- [x] 3.1 Update the ASCII sample in `README.md` under "Example output" to show only the `5 hour` and `7 day` rows plus the new `Monthly cap:` line between "Tokens consumed" and "Next reset"
+
+## 4. Manual verification
+
+- [x] 4.1 Build locally (`make build`) and run `zenmux-usage` against a real account; confirm the new layout renders and the monthly cap values match the API response

+ 22 - 6
openspec/specs/subscription-usage/spec.md

@@ -18,17 +18,17 @@ The CLI SHALL call `GET https://zenmux.ai/api/v1/management/subscription/detail`
 - **WHEN** the user runs `zenmux-usage --timeout 2s` and a server has not responded within 2 seconds
 - **THEN** the CLI aborts that account's request and records it as a timeout error while continuing with any remaining accounts
 
-### Requirement: Render three quota windows per account
+### Requirement: Render two quota windows per account
 
-In human output mode the CLI SHALL render, for each resolved account, one labeled row per window (`5 hour`, `7 day`, `month`). Each row MUST include a fixed-width progress bar, the usage percentage to two decimals, the used-vs-max flows, and the used-vs-max USD value when the API provides them. Bars MUST be color-coded by usage band: green below 60%, yellow from 60% through 85%, red above 85%. The monthly window MUST render the `max_flows` and `max_value_usd` values and display `—` (em-dash) in place of used values since the API does not return `used_*` fields for that window.
+In human output mode the CLI SHALL render, for each resolved account, one labeled row per rolling window — `5 hour` and `7 day`. Each row MUST include a fixed-width progress bar, the usage percentage to two decimals, the used-vs-max flows, and the used-vs-max USD value. Bars MUST be color-coded by usage band: green below 60%, yellow from 60% through 85%, red above 85%. The monthly quota MUST NOT be rendered as a third progress-bar row; it is emitted separately by the "Display monthly cap" requirement.
 
 #### Scenario: Healthy 5-hour window
 - **WHEN** the API returns `quota_5_hour.usage_percentage = 0.0715`, `used_flows = 57.2`, `max_flows = 800`, `used_value_usd = 1.88`, `max_value_usd = 26.26` for an account
 - **THEN** that account's block contains a row labeled `5 hour`, a green-colored bar roughly 7% filled, `7.15%`, `57.2 / 800 flows`, and `$1.88 / $26.26`
 
-#### Scenario: Monthly window has no used values
-- **WHEN** the API returns `quota_monthly` with only `max_flows` and `max_value_usd` populated
-- **THEN** the monthly row prints `—` in place of used flows and used USD, and shows the `max_flows` and `max_value_usd` values
+#### Scenario: No monthly progress row rendered
+- **WHEN** an account is rendered in human mode
+- **THEN** the output MUST NOT contain a row labeled `month` inside the progress-bar block, and MUST NOT contain an em-dash (`—`) placeholder or `n/a` percentage inside the bar rows
 
 #### Scenario: Color disabled via flag
 - **WHEN** the user runs `zenmux-usage --no-color`
@@ -52,7 +52,7 @@ For every resolved account the CLI SHALL print, before the quota rows, a header
 
 ### Requirement: Display token USD value consumed per account
 
-Below each account's three quota rows the CLI SHALL print a summary line labeled "Tokens consumed (estimated USD value)" showing the `used_value_usd` from the 7-day window for that account, formatted as USD to two decimals.
+Below each account's quota rows the CLI SHALL print a summary line labeled "Tokens consumed (estimated USD value)" showing the `used_value_usd` from the 7-day window for that account, formatted as USD to two decimals.
 
 #### Scenario: 7-day used value present
 - **WHEN** an account's `quota_7_day.used_value_usd = 13.66`
@@ -108,3 +108,19 @@ On `success: false` in a response body the CLI SHALL treat that account as a fai
 #### Scenario: Single-account run preserves specific code
 - **WHEN** a single-account run (`--account work`, `--api-key`, or env-var fallback) fails with HTTP 401
 - **THEN** the CLI exits with code 4
+
+### Requirement: Display monthly cap
+
+Below the "Tokens consumed" summary line and above any "Next reset" line the CLI SHALL, in human output mode, emit a one-line summary of the monthly quota cap using `quota_monthly.max_flows` and `quota_monthly.max_value_usd`. The line MUST be formatted as `Monthly cap: <max_flows> flows · $<max_value_usd>` with `max_flows` printed without trailing zeros and `max_value_usd` formatted as USD to two decimals.
+
+#### Scenario: Monthly cap present
+- **WHEN** an account returns `quota_monthly.max_flows = 34560` and `quota_monthly.max_value_usd = 1134.33`
+- **THEN** the output contains a line `Monthly cap: 34560 flows · $1134.33`
+
+#### Scenario: Cap line placement
+- **WHEN** the human-mode block is rendered for an account
+- **THEN** the "Monthly cap:" line appears after the "Tokens consumed (estimated USD value):" line and before the "Next reset:" line (when a reset line is emitted)
+
+#### Scenario: JSON mode unchanged
+- **WHEN** the user runs `zenmux-usage --json`
+- **THEN** no "Monthly cap" line is injected into the JSON output; the `quota_monthly` field from the API response is the only monthly representation