From 30ca472bce760a2132755118c55501adafbf8b4c Mon Sep 17 00:00:00 2001 From: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 17:02:47 +0800 Subject: [PATCH 1/3] feat: align plan tiers with upstream /status naming Upstream added ProMax and finer-grained business/edu/research wire values; /status now shows the pro family as Pro 100/Pro 200/Pro 500 and labels tiers like Business Premium and Enterprise (Automation). codex-auth normalized everything past prolite to unknown and still printed "Pro Lite"/"Pro". - PlanType gains promax, business_premium, enterprise_automation, edu_plus, edu_pro, sci, sci_plus; planLabel mirrors upstream /status display names - normalizePlanType recognizes the new wire aliases (promax, self_serve_business_prolite, enterprise_cbp_automation, ent26, edu_plus, edu_pro, sci, sci_plus); parseStoredPlanType keeps the schema-4 split so stored tag names and wire values stay distinct - list --json keeps emitting normalized tag names for the new tiers; isWorkspaceAccount treats every workspace tier as workspace - writeTruncated trims a prefix ending on a word boundary so "Pro 200"-style labels truncate to "Pro." instead of "Pro .", which read as a standalone dot-only cell --- docs/json-api.md | 9 +++++++- src/cli/json_output.zig | 10 +++++++++ src/cli/table_layout.zig | 7 ++++-- src/registry/account_ops.zig | 2 +- src/registry/common.zig | 41 +++++++++++++++++++++++++++++++++--- src/registry/parse.zig | 20 ++++++++++++++++++ tests/registry_test.zig | 34 ++++++++++++++++++++++++++++-- tests/tui_display_test.zig | 4 ++-- 8 files changed, 116 insertions(+), 11 deletions(-) diff --git a/docs/json-api.md b/docs/json-api.md index 022240cb..c574400f 100644 --- a/docs/json-api.md +++ b/docs/json-api.md @@ -107,8 +107,15 @@ period end. | Input observed by the CLI | JSON plan | |---------------------------|-----------| | `team`, `self_serve_business_usage_based` | `business` | -| `business`, `enterprise_cbp_usage_based`, `enterprise`, `hc` | `enterprise` | +| `self_serve_business_prolite` | `business_premium` | +| `business`, `ent26`, `enterprise_cbp_usage_based`, `enterprise`, `hc` | `enterprise` | +| `enterprise_cbp_automation` | `enterprise_automation` | | `education`, `edu` | `edu` | +| `edu_plus`, `edu_pro`, `sci`, `sci_plus`, `promax` | unchanged | + +The stored `plan` value (schema version 4+) keeps these same normalized +tag names; only registries written before schema version 4 still carry +raw backend identifiers and are normalized on load. The GUI must not repeat this mapping. When both auth and stored usage provide a plan, the usage plan wins. diff --git a/src/cli/json_output.zig b/src/cli/json_output.zig index ba1947c4..4ab49e6e 100644 --- a/src/cli/json_output.zig +++ b/src/cli/json_output.zig @@ -304,6 +304,9 @@ fn writeOptionalText(jw: *std.json.Stringify, value: ?[]const u8) !void { } } +/// `plan` in `list --json` is the normalized tier name (the enum tag): +/// `team`/`self_serve_business_usage_based` wire values arrive as +/// `"business"`, matching the existing `plan` contract. fn planName(plan: ?registry.PlanType) ?[]const u8 { const value = plan orelse return null; return switch (value) { @@ -312,9 +315,16 @@ fn planName(plan: ?registry.PlanType) ?[]const u8 { .plus => "plus", .prolite => "prolite", .pro => "pro", + .promax => "promax", .business => "business", + .business_premium => "business_premium", .enterprise => "enterprise", + .enterprise_automation => "enterprise_automation", .edu => "edu", + .edu_plus => "edu_plus", + .edu_pro => "edu_pro", + .sci => "sci", + .sci_plus => "sci_plus", .unknown => "unknown", }; } diff --git a/src/cli/table_layout.zig b/src/cli/table_layout.zig index 8ef4524c..4ebc5e2c 100644 --- a/src/cli/table_layout.zig +++ b/src/cli/table_layout.zig @@ -292,9 +292,12 @@ fn writeTruncated(out: *std.Io.Writer, text: []const u8, width: usize) !usize { try out.writeAll("."); return 1; } - try out.writeAll(text[0..text_width.prefixByteLength(text, width - 1)]); + // A prefix ending on a word boundary would render `word .`, which reads + // like a standalone dot cell; trim the gap so the marker hugs the text. + const prefix = std.mem.trimEnd(u8, text[0..text_width.prefixByteLength(text, width - 1)], " "); + try out.writeAll(prefix); try out.writeAll("."); - return width; + return text_width.displayWidth(prefix) + 1; } /// Usage cells read `N% (HH:MM on D Mon)`, `N% (HH:MM)` or a bare label such diff --git a/src/registry/account_ops.zig b/src/registry/account_ops.zig index b7c28cfc..286a1ed6 100644 --- a/src/registry/account_ops.zig +++ b/src/registry/account_ops.zig @@ -470,7 +470,7 @@ pub fn hasStoredAccountName(rec: *const AccountRecord) bool { pub fn isWorkspaceAccount(rec: *const AccountRecord) bool { const plan = resolveDisplayPlan(rec) orelse return false; return switch (plan) { - .business, .enterprise, .edu => true, + .business, .business_premium, .enterprise, .enterprise_automation, .edu, .edu_plus, .edu_pro, .sci, .sci_plus => true, else => false, }; } diff --git a/src/registry/common.zig b/src/registry/common.zig index 02d039ad..3eac6a87 100644 --- a/src/registry/common.zig +++ b/src/registry/common.zig @@ -3,7 +3,32 @@ const app_runtime = @import("../core/runtime.zig"); const builtin = @import("builtin"); const account_api = @import("../api/account.zig"); -pub const PlanType = enum { free, go, plus, prolite, pro, business, enterprise, edu, unknown }; +/// Normalized plan tiers, shaped by upstream's display mapping rather +/// than raw wire values: upstream remaps several wire names onto the +/// same label (`team`/`self_serve_business_usage_based` → "Business", +/// `business`/`ent26`/`hc`/`enterprise`/`enterprise_cbp_usage_based` → +/// "Enterprise"), so `business` here is upstream's "Business" tier and +/// `enterprise` is upstream's "Enterprise" tier. +pub const PlanType = enum { + free, + go, + plus, + prolite, + pro, + promax, + business, + /// Upstream `self_serve_business_prolite`. + business_premium, + enterprise, + /// Upstream `enterprise_cbp_automation`. + enterprise_automation, + edu, + edu_plus, + edu_pro, + sci, + sci_plus, + unknown, +}; pub const AuthMode = enum { chatgpt, apikey }; pub const current_schema_version: u32 = 4; pub const min_supported_schema_version: u32 = 2; @@ -159,15 +184,25 @@ pub fn resolveDisplayPlan(rec: *const AccountRecord) ?PlanType { } pub fn planLabel(plan: PlanType) []const u8 { + // Display labels mirror upstream's /status mapping + // (codex-rs/tui/src/subscription.rs): prolite→Pro 100, pro→Pro 200, + // promax→Pro 500. return switch (plan) { .free => "Free", .go => "Go", .plus => "Plus", - .prolite => "Pro Lite", - .pro => "Pro", + .prolite => "Pro 100", + .pro => "Pro 200", + .promax => "Pro 500", .business => "Business", + .business_premium => "Business Premium", .enterprise => "Enterprise", + .enterprise_automation => "Enterprise (Automation)", .edu => "Edu", + .edu_plus => "Edu Plus", + .edu_pro => "Edu Pro", + .sci => "Academic Researchers", + .sci_plus => "Academic Researchers Plus", .unknown => "Unknown", }; } diff --git a/src/registry/parse.zig b/src/registry/parse.zig index ccb89b62..64b75a2e 100644 --- a/src/registry/parse.zig +++ b/src/registry/parse.zig @@ -11,33 +11,53 @@ const CreditsSnapshot = common.CreditsSnapshot; const RateLimitResetCredit = common.RateLimitResetCredit; const ResetCreditCard = common.ResetCreditCard; +/// Wire `plan_type`/`chatgpt_plan_type` string → normalized tier, +/// mirroring upstream's KnownPlan::from_raw_value + display mapping. pub fn normalizePlanType(s: []const u8) PlanType { if (std.ascii.eqlIgnoreCase(s, "free")) return .free; if (std.ascii.eqlIgnoreCase(s, "go")) return .go; if (std.ascii.eqlIgnoreCase(s, "plus")) return .plus; if (std.ascii.eqlIgnoreCase(s, "prolite")) return .prolite; + if (std.ascii.eqlIgnoreCase(s, "promax")) return .promax; if (std.ascii.eqlIgnoreCase(s, "pro")) return .pro; + if (std.ascii.eqlIgnoreCase(s, "self_serve_business_prolite")) return .business_premium; if (std.ascii.eqlIgnoreCase(s, "team") or std.ascii.eqlIgnoreCase(s, "self_serve_business_usage_based")) return .business; if (std.ascii.eqlIgnoreCase(s, "business") or + std.ascii.eqlIgnoreCase(s, "ent26") or std.ascii.eqlIgnoreCase(s, "enterprise_cbp_usage_based") or std.ascii.eqlIgnoreCase(s, "enterprise") or std.ascii.eqlIgnoreCase(s, "hc")) return .enterprise; + if (std.ascii.eqlIgnoreCase(s, "enterprise_cbp_automation")) return .enterprise_automation; if (std.ascii.eqlIgnoreCase(s, "education") or std.ascii.eqlIgnoreCase(s, "edu")) return .edu; + if (std.ascii.eqlIgnoreCase(s, "edu_plus")) return .edu_plus; + if (std.ascii.eqlIgnoreCase(s, "edu_pro")) return .edu_pro; + if (std.ascii.eqlIgnoreCase(s, "sci")) return .sci; + if (std.ascii.eqlIgnoreCase(s, "sci_plus")) return .sci_plus; return .unknown; } pub fn parseStoredPlanType(s: []const u8, schema_version: u32) PlanType { if (schema_version < 4) return normalizePlanType(s); + // Stored values are enum tag names; match them before wire aliases + // so "business" reads as .business (the wire "business" normalizes + // to .enterprise instead). if (std.ascii.eqlIgnoreCase(s, "free")) return .free; if (std.ascii.eqlIgnoreCase(s, "go")) return .go; if (std.ascii.eqlIgnoreCase(s, "plus")) return .plus; if (std.ascii.eqlIgnoreCase(s, "prolite")) return .prolite; + if (std.ascii.eqlIgnoreCase(s, "promax")) return .promax; if (std.ascii.eqlIgnoreCase(s, "pro")) return .pro; if (std.ascii.eqlIgnoreCase(s, "business")) return .business; + if (std.ascii.eqlIgnoreCase(s, "business_premium")) return .business_premium; if (std.ascii.eqlIgnoreCase(s, "enterprise")) return .enterprise; + if (std.ascii.eqlIgnoreCase(s, "enterprise_automation")) return .enterprise_automation; if (std.ascii.eqlIgnoreCase(s, "edu")) return .edu; + if (std.ascii.eqlIgnoreCase(s, "edu_plus")) return .edu_plus; + if (std.ascii.eqlIgnoreCase(s, "edu_pro")) return .edu_pro; + if (std.ascii.eqlIgnoreCase(s, "sci")) return .sci; + if (std.ascii.eqlIgnoreCase(s, "sci_plus")) return .sci_plus; return .unknown; } diff --git a/tests/registry_test.zig b/tests/registry_test.zig index fe5df838..558ae272 100644 --- a/tests/registry_test.zig +++ b/tests/registry_test.zig @@ -406,11 +406,19 @@ test "plan labels are human-readable while registry stores canonical plan values defer gpa.free(saved); try std.testing.expect(std.mem.find(u8, saved, "\"plan\": \"prolite\"") != null); - try std.testing.expect(std.mem.find(u8, saved, "Pro Lite") == null); + try std.testing.expect(std.mem.find(u8, saved, "Pro 100") == null); try std.testing.expectEqualStrings("Free", registry.planLabel(.free)); try std.testing.expectEqualStrings("Plus", registry.planLabel(.plus)); - try std.testing.expectEqualStrings("Pro Lite", registry.planLabel(.prolite)); + // Labels mirror upstream /status: the pro family carries its quota tier. + try std.testing.expectEqualStrings("Pro 100", registry.planLabel(.prolite)); + try std.testing.expectEqualStrings("Pro 200", registry.planLabel(.pro)); + try std.testing.expectEqualStrings("Pro 500", registry.planLabel(.promax)); try std.testing.expectEqualStrings("Business", registry.planLabel(.business)); + try std.testing.expectEqualStrings("Business Premium", registry.planLabel(.business_premium)); + try std.testing.expectEqualStrings("Enterprise", registry.planLabel(.enterprise)); + try std.testing.expectEqualStrings("Enterprise (Automation)", registry.planLabel(.enterprise_automation)); + try std.testing.expectEqualStrings("Edu Plus", registry.planLabel(.edu_plus)); + try std.testing.expectEqualStrings("Academic Researchers Plus", registry.planLabel(.sci_plus)); } test "resolveDisplayPlan prefers a usage snapshot plan over the stored auth plan" { @@ -434,10 +442,32 @@ test "resolveDisplayPlan prefers a usage snapshot plan over the stored auth plan test "backend plan values normalize to final product plans" { try std.testing.expectEqual(registry.PlanType.business, registry.normalizePlanType("team")); try std.testing.expectEqual(registry.PlanType.business, registry.normalizePlanType("self_serve_business_usage_based")); + try std.testing.expectEqual(registry.PlanType.business_premium, registry.normalizePlanType("self_serve_business_prolite")); try std.testing.expectEqual(registry.PlanType.enterprise, registry.normalizePlanType("business")); + try std.testing.expectEqual(registry.PlanType.enterprise, registry.normalizePlanType("ent26")); try std.testing.expectEqual(registry.PlanType.enterprise, registry.normalizePlanType("enterprise_cbp_usage_based")); try std.testing.expectEqual(registry.PlanType.enterprise, registry.normalizePlanType("hc")); + try std.testing.expectEqual(registry.PlanType.enterprise_automation, registry.normalizePlanType("enterprise_cbp_automation")); try std.testing.expectEqual(registry.PlanType.go, registry.normalizePlanType("go")); + try std.testing.expectEqual(registry.PlanType.promax, registry.normalizePlanType("promax")); + try std.testing.expectEqual(registry.PlanType.edu_plus, registry.normalizePlanType("edu_plus")); + try std.testing.expectEqual(registry.PlanType.edu_pro, registry.normalizePlanType("edu_pro")); + try std.testing.expectEqual(registry.PlanType.sci, registry.normalizePlanType("sci")); + try std.testing.expectEqual(registry.PlanType.sci_plus, registry.normalizePlanType("sci_plus")); +} + +test "stored plan parsing splits wire aliases from schema-4 tag names" { + // Pre-schema-4 files carry raw backend identifiers; schema 4+ stores + // the normalized tag names emitted by this binary. + try std.testing.expectEqual(registry.PlanType.enterprise, registry.parseStoredPlanType("business", 3)); + try std.testing.expectEqual(registry.PlanType.business, registry.parseStoredPlanType("business", 4)); + try std.testing.expectEqual(registry.PlanType.business, registry.parseStoredPlanType("team", 3)); + try std.testing.expectEqual(registry.PlanType.unknown, registry.parseStoredPlanType("team", 4)); + try std.testing.expectEqual(registry.PlanType.business_premium, registry.parseStoredPlanType("self_serve_business_prolite", 3)); + try std.testing.expectEqual(registry.PlanType.business_premium, registry.parseStoredPlanType("business_premium", 4)); + try std.testing.expectEqual(registry.PlanType.enterprise_automation, registry.parseStoredPlanType("enterprise_cbp_automation", 3)); + try std.testing.expectEqual(registry.PlanType.enterprise_automation, registry.parseStoredPlanType("enterprise_automation", 4)); + try std.testing.expectEqual(registry.PlanType.promax, registry.parseStoredPlanType("promax", 4)); } test "registry load defaults missing account_name field to null" { diff --git a/tests/tui_display_test.zig b/tests/tui_display_test.zig index b28b8c4b..c0e9b450 100644 --- a/tests/tui_display_test.zig +++ b/tests/tui_display_test.zig @@ -102,7 +102,7 @@ test "Scenario: Given grouped accounts with aliases when building display rows t try std.testing.expect(std.mem.eql(u8, rows.rows[2].account_cell, "backup") or std.mem.eql(u8, rows.rows[2].account_cell, "work")); } -test "Scenario: Given grouped accounts with a prolite record when building display rows then labels use Pro Lite wording" { +test "Scenario: Given grouped accounts with a prolite record when building display rows then labels use Pro 100 wording" { const gpa = std.testing.allocator; var reg = makeRegistry(); defer reg.deinit(gpa); @@ -116,7 +116,7 @@ test "Scenario: Given grouped accounts with a prolite record when building displ try std.testing.expectEqual(@as(usize, 3), rows.rows.len); try std.testing.expect(std.mem.eql(u8, rows.rows[0].account_cell, "user@example.com")); try std.testing.expect(std.mem.eql(u8, rows.rows[1].account_cell, "Business")); - try std.testing.expect(std.mem.eql(u8, rows.rows[2].account_cell, "Pro Lite")); + try std.testing.expect(std.mem.eql(u8, rows.rows[2].account_cell, "Pro 100")); } test "Scenario: Given a grouped account with a fresher usage plan when building display rows then labels and ordering prefer the usage plan" { From 8e573c0fb40198d476d781a677381c10fdf15fb9 Mon Sep 17 00:00:00 2001 From: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 17:23:28 +0800 Subject: [PATCH 2/3] refactor: collapse planName to @tagName The explicit switch returned the tag spelling for every variant, and the registry file already derives the same contract from Stringify's default enum serialization, so the table only duplicated @tagName. --- src/cli/json_output.zig | 26 +++++--------------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/src/cli/json_output.zig b/src/cli/json_output.zig index 4ab49e6e..87c97e57 100644 --- a/src/cli/json_output.zig +++ b/src/cli/json_output.zig @@ -304,29 +304,13 @@ fn writeOptionalText(jw: *std.json.Stringify, value: ?[]const u8) !void { } } -/// `plan` in `list --json` is the normalized tier name (the enum tag): -/// `team`/`self_serve_business_usage_based` wire values arrive as -/// `"business"`, matching the existing `plan` contract. +/// `plan` in `list --json` is the normalized tier name — the enum tag, +/// same as the registry file's `Stringify` output. Wire aliases like +/// `team`/`self_serve_business_usage_based` have already collapsed at +/// parse time, so the tag name is the contract. fn planName(plan: ?registry.PlanType) ?[]const u8 { const value = plan orelse return null; - return switch (value) { - .free => "free", - .go => "go", - .plus => "plus", - .prolite => "prolite", - .pro => "pro", - .promax => "promax", - .business => "business", - .business_premium => "business_premium", - .enterprise => "enterprise", - .enterprise_automation => "enterprise_automation", - .edu => "edu", - .edu_plus => "edu_plus", - .edu_pro => "edu_pro", - .sci => "sci", - .sci_plus => "sci_plus", - .unknown => "unknown", - }; + return @tagName(value); } fn writeSelectorResolution( From d6fd5c766b06dd1a658e40f0887892f3da26469d Mon Sep 17 00:00:00 2001 From: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 17:26:28 +0800 Subject: [PATCH 3/3] refactor: inline the single planName call site The Option adapter had exactly one caller; @tagName reads fine at the call site with the contract note moved alongside. --- src/cli/json_output.zig | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/cli/json_output.zig b/src/cli/json_output.zig index 87c97e57..00bb81c2 100644 --- a/src/cli/json_output.zig +++ b/src/cli/json_output.zig @@ -165,7 +165,10 @@ fn writeAccount(jw: *std.json.Stringify, account: *const results.AccountView) !v try jw.objectField("account_name"); try jw.write(account.account_name); try jw.objectField("plan"); - try writeOptionalText(jw, planName(account.plan)); + // `plan` emits the normalized tier name — the enum tag, same as the + // registry file's `Stringify` output; wire aliases like `team` + // collapsed at parse time. + try writeOptionalText(jw, if (account.plan) |p| @tagName(p) else null); try jw.objectField("auth_mode"); try writeOptionalText(jw, authModeName(account.auth_mode)); try jw.objectField("access_token_expires_at"); @@ -304,15 +307,6 @@ fn writeOptionalText(jw: *std.json.Stringify, value: ?[]const u8) !void { } } -/// `plan` in `list --json` is the normalized tier name — the enum tag, -/// same as the registry file's `Stringify` output. Wire aliases like -/// `team`/`self_serve_business_usage_based` have already collapsed at -/// parse time, so the tag name is the contract. -fn planName(plan: ?registry.PlanType) ?[]const u8 { - const value = plan orelse return null; - return @tagName(value); -} - fn writeSelectorResolution( jw: *std.json.Stringify, resolution: *const results.SelectorResolutionView,