diff --git a/docs/commands/list.md b/docs/commands/list.md index 700f81dd..b5f69f38 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -16,7 +16,7 @@ codex-auth list --skip-api - Syncs the current `auth.json` into the registry before rendering when the current auth file is parseable. - Shows selectable row numbers using the same ordering as `switch` and `remove`. - Groups rows by email when the same email owns multiple account snapshots. -- Shows `ACCOUNT`, `PLAN`, `5H`, `WEEKLY`, and `LAST ACTIVITY`. +- Non-live output shows `ACCOUNT`, `PLAN`, `RESET CREDITS`, `5H`, `WEEKLY`, and `LAST ACTIVITY`. ## Refresh Modes @@ -34,6 +34,7 @@ When local-only refresh is active, only the active account can be updated from l - Singleton rows with both alias and account name render as `alias(account name, email)`. - Grouped rows keep the shared email in the header; child rows with both alias and account name render as `alias(account name)`. - Usage cells show remaining percent and reset time when that data is known. +- In non-live output, `RESET CREDITS` shows the stored reset-credit count when remote usage refresh provides it. - Remote refresh failures can render row overlays such as `401`, `403`, `TimedOut`, or `MissingAuth`. - `LAST ACTIVITY` is based on the last stored usage update time. - Shared table layout policy is documented in [docs/table-layout.md](../table-layout.md). diff --git a/src/api/usage.zig b/src/api/usage.zig index 844fc16f..ef73977d 100644 --- a/src/api/usage.zig +++ b/src/api/usage.zig @@ -261,6 +261,7 @@ pub fn parseUsageResponse(allocator: std.mem.Allocator, body: []const u8) !?regi .primary = null, .secondary = null, .credits = null, + .reset_credits = null, .plan_type = null, }; @@ -270,6 +271,9 @@ pub fn parseUsageResponse(allocator: std.mem.Allocator, body: []const u8) !?regi if (root_obj.get("credits")) |credits| { snapshot.credits = try parseCredits(allocator, credits); } + if (root_obj.get("rate_limit_reset_credits")) |reset_credits| { + snapshot.reset_credits = parseResetCredits(reset_credits); + } if (root_obj.get("rate_limit")) |rate_limit| { switch (rate_limit) { .object => |obj| { @@ -284,7 +288,7 @@ pub fn parseUsageResponse(allocator: std.mem.Allocator, body: []const u8) !?regi } } - if (snapshot.primary == null and snapshot.secondary == null) { + if (snapshot.primary == null and snapshot.secondary == null and snapshot.reset_credits == null) { if (snapshot.credits) |*credits| { if (credits.balance) |balance| allocator.free(balance); } @@ -294,6 +298,17 @@ pub fn parseUsageResponse(allocator: std.mem.Allocator, body: []const u8) !?regi return snapshot; } +fn parseResetCredits(v: std.json.Value) ?i64 { + const obj = switch (v) { + .object => |o| o, + else => return null, + }; + return switch (obj.get("available_count") orelse return null) { + .integer => |i| i, + else => null, + }; +} + fn parseWindow(v: std.json.Value) ?registry.RateLimitWindow { const obj = switch (v) { .object => |o| o, diff --git a/src/registry/common.zig b/src/registry/common.zig index 7cc2c571..27d43cc0 100644 --- a/src/registry/common.zig +++ b/src/registry/common.zig @@ -68,6 +68,7 @@ pub const RateLimitSnapshot = struct { primary: ?RateLimitWindow, secondary: ?RateLimitWindow, credits: ?CreditsSnapshot, + reset_credits: ?i64 = null, plan_type: ?PlanType, }; @@ -216,6 +217,7 @@ pub fn cloneRateLimitSnapshot(allocator: std.mem.Allocator, snapshot: RateLimitS .primary = snapshot.primary, .secondary = snapshot.secondary, .credits = cloned_credits, + .reset_credits = snapshot.reset_credits, .plan_type = snapshot.plan_type, }; } @@ -261,6 +263,7 @@ pub fn rateLimitSnapshotEqual(a: RateLimitSnapshot, b: RateLimitSnapshot) bool { return rateLimitWindowEqual(a.primary, b.primary) and rateLimitWindowEqual(a.secondary, b.secondary) and creditsEqual(a.credits, b.credits) and + a.reset_credits == b.reset_credits and a.plan_type == b.plan_type; } diff --git a/src/registry/parse.zig b/src/registry/parse.zig index 079808cc..842ad49a 100644 --- a/src/registry/parse.zig +++ b/src/registry/parse.zig @@ -32,7 +32,7 @@ pub fn parseUsage(allocator: std.mem.Allocator, v: std.json.Value) ?RateLimitSna .object => |o| o, else => return null, }; - var snap = RateLimitSnapshot{ .primary = null, .secondary = null, .credits = null, .plan_type = null }; + var snap = RateLimitSnapshot{ .primary = null, .secondary = null, .credits = null, .reset_credits = null, .plan_type = null }; if (obj.get("plan_type")) |p| { switch (p) { @@ -43,6 +43,7 @@ pub fn parseUsage(allocator: std.mem.Allocator, v: std.json.Value) ?RateLimitSna if (obj.get("primary")) |p| snap.primary = parseWindow(p); if (obj.get("secondary")) |p| snap.secondary = parseWindow(p); if (obj.get("credits")) |c| snap.credits = parseCredits(allocator, c); + snap.reset_credits = readInt(obj.get("reset_credits")); return snap; } diff --git a/src/session.zig b/src/session.zig index 3e3e2cd4..2b91db44 100644 --- a/src/session.zig +++ b/src/session.zig @@ -430,7 +430,7 @@ fn looksLikeUsageEventLine(line: []const u8) bool { } fn parseRateLimits(allocator: std.mem.Allocator, parsed: UsageRateLimitsJson) ?registry.RateLimitSnapshot { - var snap = registry.RateLimitSnapshot{ .primary = null, .secondary = null, .credits = null, .plan_type = null }; + var snap = registry.RateLimitSnapshot{ .primary = null, .secondary = null, .credits = null, .reset_credits = null, .plan_type = null }; if (parsed.primary) |p| snap.primary = parseWindow(p); if (parsed.secondary) |p| snap.secondary = parseWindow(p); if (parsed.credits) |c| snap.credits = parseCredits(allocator, c); diff --git a/src/tui/table.zig b/src/tui/table.zig index 37896bc6..090eb469 100644 --- a/src/tui/table.zig +++ b/src/tui/table.zig @@ -76,19 +76,30 @@ fn usageCellFullTextAlloc( return formatRateLimitFullAlloc(window); } +fn resetCreditsCellAlloc( + allocator: std.mem.Allocator, + usage: ?registry.RateLimitSnapshot, + usage_override: ?[]const u8, +) ![]u8 { + if (usage_override) |value| return allocator.dupe(u8, value); + const count = if (usage) |snapshot| snapshot.reset_credits else null; + return if (count) |value| std.fmt.allocPrint(allocator, "{d}", .{value}) else allocator.dupe(u8, "-"); +} + pub fn writeAccountsTableWithUsageOverrides( out: *std.Io.Writer, reg: *registry.Registry, use_color: bool, usage_overrides: ?[]const ?[]const u8, ) !void { - const headers = [_][]const u8{ "ACCOUNT", "PLAN", "5H", "WEEKLY", "LAST ACTIVITY" }; + const headers = [_][]const u8{ "ACCOUNT", "PLAN", "RESET CREDITS", "5H", "WEEKLY", "LAST ACTIVITY" }; var widths = [_]usize{ headers[0].len, headers[1].len, headers[2].len, headers[3].len, headers[4].len, + headers[5].len, }; const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); var display = try display_rows.buildDisplayRows(std.heap.page_allocator, reg, null); @@ -106,6 +117,8 @@ pub fn writeAccountsTableWithUsageOverrides( const rate_5h = resolveRateWindow(rec.last_usage, 300, true); const rate_week = resolveRateWindow(rec.last_usage, 10080, false); const usage_override = usageOverrideForAccount(usage_overrides, account_idx); + const reset_credits_str = try resetCreditsCellAlloc(std.heap.page_allocator, rec.last_usage, usage_override); + defer std.heap.page_allocator.free(reset_credits_str); const rate_5h_str = try usageCellFullTextAlloc(std.heap.page_allocator, rate_5h, usage_override); defer std.heap.page_allocator.free(rate_5h_str); const rate_week_str = try usageCellFullTextAlloc(std.heap.page_allocator, rate_week, usage_override); @@ -114,9 +127,10 @@ pub fn writeAccountsTableWithUsageOverrides( defer std.heap.page_allocator.free(last_str); widths[1] = @max(widths[1], plan.len); - widths[2] = @max(widths[2], rate_5h_str.len); - widths[3] = @max(widths[3], rate_week_str.len); - widths[4] = @max(widths[4], last_str.len); + widths[2] = @max(widths[2], reset_credits_str.len); + widths[3] = @max(widths[3], rate_5h_str.len); + widths[4] = @max(widths[4], rate_week_str.len); + widths[5] = @max(widths[5], last_str.len); } } @@ -128,12 +142,14 @@ pub fn writeAccountsTableWithUsageOverrides( defer std.heap.page_allocator.free(h1); const h2 = try truncateAlloc(headers[2], widths[2]); defer std.heap.page_allocator.free(h2); - const header_week = if (widths[3] >= "WEEKLY".len) "WEEKLY" else if (widths[3] >= "WEEK".len) "WEEK" else "W"; - const h3 = try truncateAlloc(header_week, widths[3]); + const h3 = try truncateAlloc(headers[3], widths[3]); defer std.heap.page_allocator.free(h3); - const header_last = if (widths[4] >= "LAST ACTIVITY".len) "LAST ACTIVITY" else "LAST"; - const h4 = try truncateAlloc(header_last, widths[4]); + const header_week = if (widths[4] >= "WEEKLY".len) "WEEKLY" else if (widths[4] >= "WEEK".len) "WEEK" else "W"; + const h4 = try truncateAlloc(header_week, widths[4]); defer std.heap.page_allocator.free(h4); + const header_last = if (widths[5] >= "LAST ACTIVITY".len) "LAST ACTIVITY" else "LAST"; + const h5 = try truncateAlloc(header_last, widths[5]); + defer std.heap.page_allocator.free(h5); if (use_color) try out.writeAll(ansi.cyan); try writeRepeat(out, ' ', prefix_len); @@ -146,6 +162,8 @@ pub fn writeAccountsTableWithUsageOverrides( try writePadded(out, h3, widths[3]); try out.writeAll(" "); try writePadded(out, h4, widths[4]); + try out.writeAll(" "); + try writePadded(out, h5, widths[5]); try out.writeAll("\n"); if (use_color) try out.writeAll(ansi.reset); if (use_color) try out.writeAll(ansi.dim); @@ -161,9 +179,11 @@ pub fn writeAccountsTableWithUsageOverrides( const rate_5h = resolveRateWindow(rec.last_usage, 300, true); const rate_week = resolveRateWindow(rec.last_usage, 10080, false); const usage_override = usageOverrideForAccount(usage_overrides, account_idx); - const rate_5h_str = try usageCellTextAlloc(std.heap.page_allocator, rate_5h, widths[2], usage_override); + const reset_credits_str = try resetCreditsCellAlloc(std.heap.page_allocator, rec.last_usage, usage_override); + defer std.heap.page_allocator.free(reset_credits_str); + const rate_5h_str = try usageCellTextAlloc(std.heap.page_allocator, rate_5h, widths[3], usage_override); defer std.heap.page_allocator.free(rate_5h_str); - const rate_week_str = try usageCellTextAlloc(std.heap.page_allocator, rate_week, widths[3], usage_override); + const rate_week_str = try usageCellTextAlloc(std.heap.page_allocator, rate_week, widths[4], usage_override); defer std.heap.page_allocator.free(rate_week_str); const last = try timefmt.formatRelativeTimeOrDashAlloc(std.heap.page_allocator, rec.last_usage_at, now); defer std.heap.page_allocator.free(last); @@ -173,11 +193,13 @@ pub fn writeAccountsTableWithUsageOverrides( defer std.heap.page_allocator.free(account_cell); const plan_cell = try truncateAlloc(plan, widths[1]); defer std.heap.page_allocator.free(plan_cell); - const rate_5h_cell = try truncateAlloc(rate_5h_str, widths[2]); + const reset_credits_cell = try truncateAlloc(reset_credits_str, widths[2]); + defer std.heap.page_allocator.free(reset_credits_cell); + const rate_5h_cell = try truncateAlloc(rate_5h_str, widths[3]); defer std.heap.page_allocator.free(rate_5h_cell); - const rate_week_cell = try truncateAlloc(rate_week_str, widths[3]); + const rate_week_cell = try truncateAlloc(rate_week_str, widths[4]); defer std.heap.page_allocator.free(rate_week_cell); - const last_cell = try truncateAlloc(last, widths[4]); + const last_cell = try truncateAlloc(last, widths[5]); defer std.heap.page_allocator.free(last_cell); if (use_color) { if (row.is_active) { @@ -194,11 +216,13 @@ pub fn writeAccountsTableWithUsageOverrides( try out.writeAll(" "); try writePadded(out, plan_cell, widths[1]); try out.writeAll(" "); - try writePadded(out, rate_5h_cell, widths[2]); + try writePadded(out, reset_credits_cell, widths[2]); + try out.writeAll(" "); + try writePadded(out, rate_5h_cell, widths[3]); try out.writeAll(" "); - try writePadded(out, rate_week_cell, widths[3]); + try writePadded(out, rate_week_cell, widths[4]); try out.writeAll(" "); - try writePadded(out, last_cell, widths[4]); + try writePadded(out, last_cell, widths[5]); try out.writeAll("\n"); if (use_color) try out.writeAll(ansi.reset); selectable_counter += 1; @@ -282,14 +306,14 @@ fn writeRepeat(out: *std.Io.Writer, ch: u8, count: usize) !void { } } -fn listTotalWidth(widths: *const [5]usize, prefix_len: usize, sep_len: usize) usize { +fn listTotalWidth(widths: *const [6]usize, prefix_len: usize, sep_len: usize) usize { var sum: usize = prefix_len; for (widths) |w| sum += w; sum += sep_len * (widths.len - 1); return sum; } -fn adjustListWidths(widths: *[5]usize, prefix_len: usize, sep_len: usize) void { +fn adjustListWidths(widths: *[6]usize, prefix_len: usize, sep_len: usize) void { const term_cols = terminalWidth(); if (term_cols == 0) return; const total = listTotalWidth(widths, prefix_len, sep_len); @@ -297,6 +321,7 @@ fn adjustListWidths(widths: *[5]usize, prefix_len: usize, sep_len: usize) void { const min_email: usize = 10; const min_plan: usize = 4; + const min_resets: usize = 1; const min_rate: usize = 1; const min_last: usize = 4; @@ -319,8 +344,8 @@ fn adjustListWidths(widths: *[5]usize, prefix_len: usize, sep_len: usize) void { } if (over == 0) return; - if (widths[2] > min_rate) { - const reducible = widths[2] - min_rate; + if (widths[2] > min_resets) { + const reducible = widths[2] - min_resets; const reduce = @min(reducible, over); widths[2] -= reduce; over -= reduce; @@ -335,12 +360,20 @@ fn adjustListWidths(widths: *[5]usize, prefix_len: usize, sep_len: usize) void { } if (over == 0) return; - if (widths[4] > min_last) { - const reducible = widths[4] - min_last; + if (widths[4] > min_rate) { + const reducible = widths[4] - min_rate; const reduce = @min(reducible, over); widths[4] -= reduce; over -= reduce; } + if (over == 0) return; + + if (widths[5] > min_last) { + const reducible = widths[5] - min_last; + const reduce = @min(reducible, over); + widths[5] -= reduce; + over -= reduce; + } } fn adjustTableWidths(widths: []usize) void { diff --git a/tests/api_usage_test.zig b/tests/api_usage_test.zig index 2b5c7615..bf05763f 100644 --- a/tests/api_usage_test.zig +++ b/tests/api_usage_test.zig @@ -46,6 +46,9 @@ test "parse usage api response maps live usage windows and plan" { \\ "approx_local_messages": null, \\ "approx_cloud_messages": null \\ }, + \\ "rate_limit_reset_credits": { + \\ "available_count": 3 + \\ }, \\ "promo": null \\} ; @@ -61,6 +64,7 @@ test "parse usage api response maps live usage windows and plan" { try std.testing.expect(snapshot.credits != null); try std.testing.expect(!snapshot.credits.?.has_credits); try std.testing.expect(snapshot.credits.?.balance == null); + try std.testing.expectEqual(@as(?i64, 3), snapshot.reset_credits); } test "parse usage api response without windows is ignored" { @@ -81,6 +85,26 @@ test "parse usage api response without windows is ignored" { try std.testing.expect(snapshot == null); } +test "parse usage api response keeps reset credits without windows" { + const gpa = std.testing.allocator; + const body = + \\{ + \\ "plan_type": "plus", + \\ "rate_limit": null, + \\ "rate_limit_reset_credits": { + \\ "available_count": 4 + \\ } + \\} + ; + + const snapshot = (try usage_api.parseUsageResponse(gpa, body)) orelse return error.TestExpectedEqual; + defer registry.freeRateLimitSnapshot(gpa, &snapshot); + + try std.testing.expect(snapshot.primary == null); + try std.testing.expect(snapshot.secondary == null); + try std.testing.expectEqual(@as(?i64, 4), snapshot.reset_credits); +} + test "parse usage api response maps prolite plan" { const gpa = std.testing.allocator; const body = diff --git a/tests/tui_table_test.zig b/tests/tui_table_test.zig index 8ebec328..da6d0b58 100644 --- a/tests/tui_table_test.zig +++ b/tests/tui_table_test.zig @@ -143,6 +143,29 @@ test "writeAccountsTable keeps usage headers short" { try std.testing.expect(std.mem.indexOf(u8, output, "USAGE") == null); } +test "writeAccountsTable shows reset credits column" { + const gpa = std.testing.allocator; + var reg = makeTestRegistry(); + defer reg.deinit(gpa); + + try appendTestAccount(gpa, ®, "user-1::acc-1", "user@example.com", "", .plus); + reg.accounts.items[0].last_usage = .{ + .primary = null, + .secondary = null, + .credits = null, + .reset_credits = 2, + .plan_type = .plus, + }; + + var buffer: [2048]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buffer); + try writeAccountsTable(&writer, ®, false); + + const output = writer.buffered(); + try std.testing.expect(std.mem.indexOf(u8, output, "RESET CREDITS") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "Plus 2") != null); +} + test "writeAccountsTable shows usage override statuses for failed refreshes" { const gpa = std.testing.allocator; var reg = makeTestRegistry(); @@ -158,7 +181,7 @@ test "writeAccountsTable shows usage override statuses for failed refreshes" { try writeAccountsTableWithUsageOverrides(&writer, ®, false, &usage_overrides); const output = writer.buffered(); - try std.testing.expect(std.mem.count(u8, output, "403") >= 2); + try std.testing.expect(std.mem.count(u8, output, "403") >= 3); } test "writeAccountsTable highlights usage override rows in red when color is enabled" {