Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/commands/list.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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).
17 changes: 16 additions & 1 deletion src/api/usage.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand All @@ -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| {
Expand All @@ -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);
}
Expand All @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/registry/common.zig
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ pub const RateLimitSnapshot = struct {
primary: ?RateLimitWindow,
secondary: ?RateLimitWindow,
credits: ?CreditsSnapshot,
reset_credits: ?i64 = null,
plan_type: ?PlanType,
};

Expand Down Expand Up @@ -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,
};
}
Expand Down Expand Up @@ -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;
}

Expand Down
3 changes: 2 additions & 1 deletion src/registry/parse.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
}

Expand Down
2 changes: 1 addition & 1 deletion src/session.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
77 changes: 55 additions & 22 deletions src/tui/table.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
}
}

Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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) {
Expand All @@ -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;
Expand Down Expand Up @@ -282,21 +306,22 @@ 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);
if (total <= term_cols) return;

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;

Expand All @@ -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;
Expand All @@ -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 {
Expand Down
24 changes: 24 additions & 0 deletions tests/api_usage_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
\\}
;
Expand All @@ -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" {
Expand All @@ -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 =
Expand Down
25 changes: 24 additions & 1 deletion tests/tui_table_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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, &reg, "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, &reg, 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();
Expand All @@ -158,7 +181,7 @@ test "writeAccountsTable shows usage override statuses for failed refreshes" {
try writeAccountsTableWithUsageOverrides(&writer, &reg, 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" {
Expand Down
Loading