diff --git a/README.md b/README.md index 9a5cd88b..a193800d 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ Detailed command documentation lives in [docs/commands/README.md](./docs/command | [`codex-auth remove [--live] [--api\|--skip-api]`](./docs/commands/remove.md) | Remove accounts interactively | | [`codex-auth remove [...]`](./docs/commands/remove.md) | Remove accounts by selector | | [`codex-auth remove --all`](./docs/commands/remove.md) | Remove all stored accounts | +| [`codex-auth reset `](./docs/commands/reset.md) | Use the soonest-expiring reset credit for one account | | [`codex-auth alias set `](./docs/commands/alias.md) | Set an alias for an account | | [`codex-auth alias clear `](./docs/commands/alias.md) | Clear the alias for an account | diff --git a/docs/commands/README.md b/docs/commands/README.md index 8dd102ee..69577d51 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -12,6 +12,7 @@ This directory documents command behavior by command. Use `codex-auth | `export` | [docs/commands/export.md](./export.md) | | `switch` | [docs/commands/switch.md](./switch.md) | | `remove` | [docs/commands/remove.md](./remove.md) | +| `reset` | [docs/commands/reset.md](./reset.md) | | `alias` | [docs/commands/alias.md](./alias.md) | | `clean` | [docs/commands/clean.md](./clean.md) | | `config` | [docs/commands/config.md](./config.md) | diff --git a/docs/commands/list.md b/docs/commands/list.md index a78a084d..382fd121 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -41,6 +41,18 @@ codex-auth list --json details may be cached. - `--json` emits one machine-readable JSON document and cannot be combined with `--live`. +## Live Reset Credits + +- With `--live`, `c` expands or collapses the reset-credit rail. The rail attaches to the row under the *rail cursor*, not to the highlighted switch target; the footer lists the keys (`Keys: ↑/↓ scroll, j/k row, c credits, PgUp/PgDn/Home/End page, Esc/q quit`). +- `j` and `k` move the rail cursor between rendered account rows. Group header rows (several accounts sharing one email) are not rows, so the rail always lands on the account drawn on that line. +- While the rail is open the list scrolls to keep that row visible, so a redeem is never confirmed or reported on a line outside the viewport. +- With the rail closed, `↑`/`↓` scroll the table. With it open they move the credit highlight inside the rail. +- The rail header reads `Available N · ⏎ select · esc close`. +- `⏎` selects the highlighted credit; pressing `⏎` again redeems it (two-step confirm). The confirmation is bound to the exact account and credit it was armed for: moving the credit highlight (`↑`/`↓`), moving the rail cursor (`j`/`k`), collapsing with `c`, closing the rail, or a refresh that changes which credit is highlighted all drop it, so a redeem only fires while the credit showing `⏎ again to use` is the one being confirmed. A failed redeem also drops it; retrying asks for a fresh `⏎` `⏎` pair and reuses the same idempotency key for that credit. +- While the rail is open, both `esc` and `q` close the rail instead of quitting the session. +- A successful redeem refreshes that account immediately, so the `5H`/`WEEKLY` cells and the remaining card list reflect the reset without waiting for the next refresh round. +- Rows show the card title, the local `MM-DD HH:MM` expiry, and a relative countdown; credits without an expiry render `-`. + When local-only refresh is active, only the active account can be updated from local rollout files. Non-active rows use the stored registry snapshot. ## Output Notes @@ -52,12 +64,12 @@ When local-only refresh is active, only the active account can be updated from l - In non-live output, `CREDITS` shows the integer part of the current `credits.balance` value. - `PLAN EXPIRE` renders the local date from the stored `id_token` subscription claim. Missing or past timestamps render as `-` in both plain and live tables. -- `RESET CREDITS` remains a separate field that shows the stored reset-credit count when remote usage refresh provides it. A zero count renders as `0`; a non-zero count with no known expiry renders as `N (-)`. +- `RESET CREDITS` remains a separate field that shows the stored reset-credit count when remote usage refresh provides it. A zero count renders as `0`; a non-zero count appends the earliest available credit expiry as `N (exp MMM-DD)`, or `N (-)` when no available credit has an expiry. - JSON output includes the raw `plan_expires_at` timestamp and the returned reset-credit details. The terminal column hides past plan-expiry timestamps, but the JSON value remains the stored timestamp so callers can detect stale snapshots. - 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. +- `LAST ACTIVITY` is based on the last stored usage update time. Its header is shown in full when the column is at least 13 columns wide and collapses to `LAST` otherwise; the same rule applies to plain and live output. - `--json` returns accounts in the same display order and includes the same row numbers shown by the table. - JSON plan values are final product plans; callers do not map backend Team/Business identifiers. - JSON usage keeps the displayable snapshot separate from the current invocation's refresh result, so cached values remain visible after refresh failure. diff --git a/docs/commands/reset.md b/docs/commands/reset.md new file mode 100644 index 00000000..54c4208d --- /dev/null +++ b/docs/commands/reset.md @@ -0,0 +1,28 @@ +# `codex-auth reset` + +## Usage + +```shell +codex-auth reset +``` + +## Behavior + +`codex-auth reset` redeems one reset credit for a single stored account. + +- The account selector accepts the same forms as `switch` and must resolve to exactly one account. +- The command picks the available credit (`status == "available"`) with the earliest `expires_at` from the stored `last_usage.reset_credit` snapshot. Credits without an expiry are used only when no dated credit is available. +- The chosen credit is redeemed through `POST wham/rate-limit-reset-credits/consume` with a freshly generated UUID v4 `redeem_request_id`. +- On success it prints two compact lines: the consumed credit (title and local expiry) and a remaining summary with the count and the card that expires next: + + ```text + used "Full reset (Weekly + 5 hr)" · expires 2026-11-01 08:00 + remaining: 1 · next: (untitled) · - + ``` +- The registry file is not rewritten by this command. The consumed credit disappears from the account list on the next `codex-auth list --api` refresh; run that refresh when the backend state changed elsewhere. +- Exit code is `0` after a successful consume (including the backend reporting `already_redeemed`) and `1` when the account has no available credits, the backend reports `no_credit` or `nothing_to_reset`, or the request fails. + +## Notes + +- Only ChatGPT-auth accounts can redeem credits; API-key accounts fail with `MissingAuth`. +- Requests use the shared curl budget with a 5-second timeout. diff --git a/docs/dynamic-columns.md b/docs/dynamic-columns.md index b272f035..a04b5c48 100644 --- a/docs/dynamic-columns.md +++ b/docs/dynamic-columns.md @@ -25,7 +25,7 @@ The order of each view is fixed. Unsupported columns are simply omitted. | View | Visual order | |---|---| | `list` | `ACCOUNT`, `PLAN`, `PLAN EXPIRE`, `CREDITS`, `RESET CREDITS`, `5H`, `WEEKLY`, `AUTH EXPIRE`, `LAST ACTIVITY` | -| `list --live` | `#*`, `ACCOUNT`, `PLAN`, `PLAN EXPIRE`, `RESET CREDITS`, `5H`, `WEEKLY`, `AUTH EXPIRE`, `LAST` | +| `list --live` | `#*`, `ACCOUNT`, `PLAN`, `PLAN EXPIRE`, `RESET CREDITS`, `5H`, `WEEKLY`, `AUTH EXPIRE`, `LAST ACTIVITY` | | `switch`, `remove` | `ACCOUNT`, `PLAN`, `5H`, `WEEKLY`, `LAST` | ## Shared Priority @@ -127,6 +127,12 @@ Wide headers use their full label. Narrow columns use compact labels: | `RESET CREDITS` | `RESET`, then `RC` | | `AUTH EXPIRE` | `AUTH EXP`, then `AUTH` | | `PLAN EXPIRE` | `PLAN EXP`, then `PLAN` | +| `LAST ACTIVITY` | `LAST` | + +`LAST ACTIVITY` renders its full header at 13 columns or wider and falls back +to `LAST` below that. Plain `list` and `list --live` use the same allocator and +deferred order, so the abbreviation rule is identical; the exact terminal width +where it kicks in can differ because each view supports a different column set. ## Adding a Column diff --git a/src/api/http.zig b/src/api/http.zig index bfb34d9d..0ee95077 100644 --- a/src/api/http.zig +++ b/src/api/http.zig @@ -23,6 +23,7 @@ pub const ChildCaptureResult = types.ChildCaptureResult; pub const runGetJsonCommand = curl.runGetJsonCommand; pub const runBearerGetJsonCommand = curl.runBearerGetJsonCommand; pub const runGetJsonBatchCommand = curl.runGetJsonBatchCommand; +pub const runPostJsonCommand = curl.runCurlPostJsonCommand; pub const ensureCurlExecutableAvailable = curl.ensureCurlExecutableAvailable; pub const resolveCurlExecutableAlloc = curl.resolveCurlExecutableAlloc; diff --git a/src/api/http_curl.zig b/src/api/http_curl.zig index 4fa84559..ea415242 100644 --- a/src/api/http_curl.zig +++ b/src/api/http_curl.zig @@ -122,6 +122,66 @@ fn runCurlBearerGetJsonCommand( return try runCurlJsonCommand(allocator, endpoint, &[_][]const u8{authorization}); } +pub fn runCurlPostJsonCommand( + allocator: std.mem.Allocator, + endpoint: []const u8, + access_token: []const u8, + account_id: []const u8, + body_json: []const u8, +) !HttpResult { + const curl_executable = try resolveCurlExecutableForLaunchAlloc(allocator); + defer allocator.free(curl_executable); + + const authorization = try std.fmt.allocPrint(allocator, "Authorization: Bearer {s}", .{access_token}); + defer allocator.free(authorization); + const account_header = try std.fmt.allocPrint(allocator, "ChatGPT-Account-Id: {s}", .{account_id}); + defer allocator.free(account_header); + + var argv = std.ArrayList([]const u8).empty; + defer argv.deinit(allocator); + try appendCurlBaseArgs(allocator, &argv, curl_executable); + + var curl_config = std.ArrayList(u8).empty; + defer curl_config.deinit(allocator); + try appendCurlConfigLine(allocator, &curl_config, "url", endpoint); + try appendCurlConfigLine(allocator, &curl_config, "header", "User-Agent: " ++ user_agent); + try appendCurlConfigLine(allocator, &curl_config, "header", "Accept: application/json"); + try appendCurlConfigLine(allocator, &curl_config, "header", authorization); + try appendCurlConfigLine(allocator, &curl_config, "header", account_header); + try appendCurlConfigLine(allocator, &curl_config, "header", "Content-Type: application/json"); + try appendCurlConfigLine(allocator, &curl_config, "data", body_json); + + const result = runChildCaptureWithInputAndOutputLimit( + allocator, + argv.items, + curl_config.items, + child_process_timeout_ms_value, + null, + default_max_output_bytes, + ) catch |err| switch (err) { + error.OutOfMemory => return err, + error.FileNotFound => return error.CurlRequired, + else => return err, + }; + defer result.deinit(allocator); + + if (result.timed_out) return error.TimedOut; + + switch (result.term) { + .exited => |code| if (code != 0) { + if (code == curl_timeout_exit_code) return error.TimedOut; + return error.RequestFailed; + }, + else => return error.RequestFailed, + } + + const parsed = try parseCurlHttpOutput(allocator, result.stdout); + return .{ + .body = parsed.body, + .status_code = parsed.status_code, + }; +} + fn runCurlGetJsonCommand( allocator: std.mem.Allocator, endpoint: []const u8, diff --git a/src/api/reset_credits.zig b/src/api/reset_credits.zig new file mode 100644 index 00000000..7ce265fd --- /dev/null +++ b/src/api/reset_credits.zig @@ -0,0 +1,154 @@ +const std = @import("std"); +const app_runtime = @import("../core/runtime.zig"); +const registry = @import("../registry/root.zig"); +const auth = @import("../auth/auth.zig"); +const http = @import("http.zig"); + +pub const default_reset_credits_consume_endpoint = + "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume"; + +/// Outcome of one consume request, mirroring upstream's +/// `ConsumeRateLimitResetCreditCode` (`reset` / `nothing_to_reset` / +/// `no_credit` / `already_redeemed`). +pub const ConsumeOutcome = enum { + reset, + already_redeemed, + nothing_to_reset, + no_credit, + unexpected_response, + http_error, + request_error, + + pub fn isSuccess(self: ConsumeOutcome) bool { + return self == .reset or self == .already_redeemed; + } +}; + +pub const ConsumeResult = struct { + outcome: ConsumeOutcome, + status_code: ?u16 = null, + error_name: ?[]const u8 = null, + body: ?[]const u8 = null, + + pub fn deinit(self: *ConsumeResult, allocator: std.mem.Allocator) void { + if (self.error_name) |name| allocator.free(name); + if (self.body) |b| allocator.free(b); + self.* = undefined; + } +}; + +/// Maps the backend `code` field of `ConsumeRateLimitResetCreditResponse` to +/// the local outcome. Unknown or missing codes are unexpected responses. +pub fn parseConsumeOutcome(allocator: std.mem.Allocator, body: []const u8) ConsumeOutcome { + var parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .unexpected_response; + defer parsed.deinit(); + const root = switch (parsed.value) { + .object => |obj| obj, + else => return .unexpected_response, + }; + const code = switch (root.get("code") orelse return .unexpected_response) { + .string => |s| s, + else => return .unexpected_response, + }; + if (std.mem.eql(u8, code, "reset")) return .reset; + if (std.mem.eql(u8, code, "already_redeemed")) return .already_redeemed; + if (std.mem.eql(u8, code, "nothing_to_reset")) return .nothing_to_reset; + if (std.mem.eql(u8, code, "no_credit")) return .no_credit; + return .unexpected_response; +} + +/// Mirrors upstream: the client generates the idempotency key (UUID v4) and +/// sends it as `redeem_request_id` together with the chosen `credit_id`. +/// Callers must reuse the same key when retrying the same credit so the +/// backend deduplicates the request instead of consuming a second credit. +pub fn generateIdempotencyKeyAlloc(allocator: std.mem.Allocator) ![]u8 { + var raw: [16]u8 = undefined; + app_runtime.io().random(&raw); + raw[6] = (raw[6] & 0x0f) | 0x40; + raw[8] = (raw[8] & 0x3f) | 0x80; + const hex = "0123456789abcdef"; + var out: [36]u8 = undefined; + var read: usize = 0; + var write: usize = 0; + while (read < 16) : (read += 1) { + if (write == 8 or write == 13 or write == 18 or write == 23) { + out[write] = '-'; + write += 1; + } + out[write] = hex[raw[read] >> 4]; + out[write + 1] = hex[raw[read] & 0x0f]; + write += 2; + } + return allocator.dupe(u8, out[0..]); +} + +/// Builds the consume request body. `credit_id` originates at the backend and +/// round-trips through the stored registry, so both fields are JSON-encoded +/// instead of interpolated into body text. +pub fn buildConsumeBodyAlloc( + allocator: std.mem.Allocator, + redeem_request_id: []const u8, + credit_id: []const u8, +) ![]u8 { + return std.json.Stringify.valueAlloc(allocator, .{ + .redeem_request_id = redeem_request_id, + .credit_id = credit_id, + }, .{}); +} + +fn fail(allocator: std.mem.Allocator, name: []const u8) ConsumeResult { + return .{ + .outcome = .request_error, + .status_code = null, + .error_name = allocator.dupe(u8, name) catch null, + .body = null, + }; +} + +/// Redeems one reset credit for the given account via the backend consume +/// endpoint, using `redeem_request_id` as the idempotency key. The account +/// must be a ChatGPT-auth account with a usable token. +pub fn consumeCreditForAccount( + allocator: std.mem.Allocator, + codex_home: []const u8, + account_key: []const u8, + redeem_request_id: []const u8, + credit_id: []const u8, +) !ConsumeResult { + const auth_path = try registry.accountAuthPath(allocator, codex_home, account_key); + defer allocator.free(auth_path); + + const info = auth.parseAuthInfo(allocator, auth_path) catch |err| return fail(allocator, @errorName(err)); + defer info.deinit(allocator); + + if (info.auth_mode != .chatgpt) return fail(allocator, "MissingAuth"); + const access_token = info.access_token orelse return fail(allocator, "MissingAuth"); + const chatgpt_account_id = info.chatgpt_account_id orelse return fail(allocator, "MissingAuth"); + + const body = try buildConsumeBodyAlloc(allocator, redeem_request_id, credit_id); + defer allocator.free(body); + + const http_result = http.runPostJsonCommand( + allocator, + default_reset_credits_consume_endpoint, + access_token, + chatgpt_account_id, + body, + ) catch |err| return fail(allocator, @errorName(err)); + + const status_code = http_result.status_code; + if (status_code == null or status_code.? < 200 or status_code.? >= 300) { + return .{ + .outcome = .http_error, + .status_code = status_code, + .error_name = null, + .body = http_result.body, + }; + } + return .{ + .outcome = parseConsumeOutcome(allocator, http_result.body), + .status_code = status_code, + .error_name = null, + .body = http_result.body, + }; +} diff --git a/src/cli/commands/reset.zig b/src/cli/commands/reset.zig new file mode 100644 index 00000000..a9d2411c --- /dev/null +++ b/src/cli/commands/reset.zig @@ -0,0 +1,27 @@ +const std = @import("std"); +const types = @import("../types.zig"); +const common = @import("common.zig"); + +pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.ParseResult { + if (args.len == 1 and common.isHelpFlag(std.mem.sliceTo(args[0], 0))) { + return .{ .command = .{ .help = .reset_credits } }; + } + + var selector: ?[]u8 = null; + for (args) |raw_arg| { + const arg = std.mem.sliceTo(raw_arg, 0); + if (std.mem.startsWith(u8, arg, "-")) { + if (selector) |value| allocator.free(value); + return common.usageErrorResult(allocator, .reset_credits, "unknown flag `{s}` for `reset`.", .{arg}); + } + if (selector != null) { + allocator.free(selector.?); + return common.usageErrorResult(allocator, .reset_credits, "unexpected extra argument `{s}` for `reset`.", .{arg}); + } + selector = try allocator.dupe(u8, arg); + } + if (selector == null) { + return common.usageErrorResult(allocator, .reset_credits, "`reset` requires an account selector (alias, email, display number, or partial query).", .{}); + } + return .{ .command = .{ .reset_credits = .{ .selector = selector.? } } }; +} diff --git a/src/cli/commands/root.zig b/src/cli/commands/root.zig index 2a311420..985de0f0 100644 --- a/src/cli/commands/root.zig +++ b/src/cli/commands/root.zig @@ -11,6 +11,7 @@ const import_auth = @import("import.zig"); const list = @import("list.zig"); const login = @import("login.zig"); const remove = @import("remove.zig"); +const reset = @import("reset.zig"); const switch_account = @import("switch.zig"); pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.ParseResult { @@ -54,6 +55,7 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !type if (std.mem.eql(u8, cmd, "export")) return export_auth.parse(allocator, args[2..]); if (std.mem.eql(u8, cmd, "switch")) return switch_account.parse(allocator, args[2..]); if (std.mem.eql(u8, cmd, "remove")) return remove.parse(allocator, args[2..]); + if (std.mem.eql(u8, cmd, "reset")) return reset.parse(allocator, args[2..]); if (std.mem.eql(u8, cmd, "alias")) return alias.parse(allocator, args[2..]); if (std.mem.eql(u8, cmd, "clean")) return clean.parse(allocator, args[2..]); if (std.mem.eql(u8, cmd, "config")) return config.parse(allocator, args[2..]); @@ -84,6 +86,7 @@ fn freeCommand(allocator: std.mem.Allocator, cmd: *types.Command) void { common.freeOwnedStringList(allocator, opts.selectors); allocator.free(opts.selectors); }, + .reset_credits => |opts| allocator.free(opts.selector), .alias => |opts| switch (opts) { .set => |set_opts| { allocator.free(set_opts.selector); @@ -116,6 +119,7 @@ fn helpTopicForName(name: []const u8) ?types.HelpTopic { if (std.mem.eql(u8, name, "export")) return .export_auth; if (std.mem.eql(u8, name, "switch")) return .switch_account; if (std.mem.eql(u8, name, "remove")) return .remove_account; + if (std.mem.eql(u8, name, "reset")) return .reset_credits; if (std.mem.eql(u8, name, "alias")) return .alias; if (std.mem.eql(u8, name, "clean")) return .clean; if (std.mem.eql(u8, name, "config")) return .config; diff --git a/src/cli/help.zig b/src/cli/help.zig index 2f2041f8..5a68d5d8 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -58,6 +58,7 @@ pub fn writeHelp( try writeCommandSummary(out, use_color, "alias", "Set or clear account aliases"); try writeCommandDetail(out, use_color, "alias set "); try writeCommandDetail(out, use_color, "alias clear "); + try writeCommandSummary(out, use_color, "reset ", "Use the soonest-expiring reset credit"); try writeCommandSummary(out, use_color, "clean", "Delete backup and stale files under accounts/"); try writeCommandDetail(out, use_color, "clean background"); try writeCommandSummary(out, use_color, "config", "Manage configuration"); @@ -134,6 +135,7 @@ fn commandNameForTopic(topic: HelpTopic) []const u8 { .export_auth => "export", .switch_account => "switch", .remove_account => "remove", + .reset_credits => "reset", .alias => "alias", .clean => "clean", .config => "config", @@ -150,6 +152,7 @@ fn commandDescriptionForTopic(topic: HelpTopic) []const u8 { .export_auth => "Export stored account auth files.", .switch_account => "Switch the active account by alias, email, display number, or partial query.", .remove_account => "Remove one or more accounts by alias, email, display number, or partial query.", + .reset_credits => "Use the available reset credit that expires first and list what remains.", .alias => "Set or clear an account alias by alias, email, display number, or partial query.", .clean => "Delete backup and stale files under accounts/.", .config => "Manage live refresh configuration.", @@ -160,20 +163,21 @@ fn commandDescriptionForTopic(topic: HelpTopic) []const u8 { fn commandHelpHasExamples(topic: HelpTopic) bool { return switch (topic) { .import_auth, .export_auth, .switch_account, .remove_account, .alias, .config, .app => true, + .reset_credits => true, else => false, }; } fn commandHelpHasOptions(topic: HelpTopic) bool { return switch (topic) { - .list, .login, .import_auth, .export_auth, .switch_account, .remove_account, .alias, .config, .app => true, + .list, .login, .import_auth, .export_auth, .switch_account, .remove_account, .reset_credits, .alias, .config, .app => true, else => false, }; } fn commandHelpHasNotes(topic: HelpTopic) bool { return switch (topic) { - .switch_account, .alias => true, + .switch_account, .alias, .reset_credits => true, else => false, }; } @@ -223,6 +227,7 @@ fn writeUsageLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth remove --all\n"); try out.writeAll(" codex-auth remove (...|--all) --json\n"); }, + .reset_credits => try out.writeAll(" codex-auth reset \n"), .alias => { try out.writeAll(" codex-auth alias set \n"); try out.writeAll(" codex-auth alias clear \n"); @@ -249,6 +254,7 @@ pub fn helpCommandForTopic(topic: HelpTopic) []const u8 { .export_auth => "codex-auth export --help", .switch_account => "codex-auth switch --help", .remove_account => "codex-auth remove --help", + .reset_credits => "codex-auth reset --help", .alias => "codex-auth alias --help", .clean => "codex-auth clean --help", .config => "codex-auth config --help", @@ -305,6 +311,10 @@ fn writeOptionLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" ...\n"); try out.writeAll(" Remove one or more matching accounts.\n"); }, + .reset_credits => { + try out.writeAll(" \n"); + try out.writeAll(" Select one stored account with an available reset credit.\n"); + }, .alias => { try out.writeAll(" set \n"); try out.writeAll(" Set one stored account alias without remote refresh.\n"); @@ -375,6 +385,9 @@ fn writeExampleLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth switch 02\n"); try out.writeAll(" codex-auth switch work\n"); }, + .reset_credits => { + try out.writeAll(" codex-auth reset user@example.com\n"); + }, .remove_account => { try out.writeAll(" codex-auth remove\n"); try out.writeAll(" codex-auth remove --live\n"); @@ -412,6 +425,10 @@ fn writeNotesSectionStyled(out: *std.Io.Writer, use_color: bool, topic: HelpTopi .switch_account => { try out.writeAll(" Targets can be `-`, aliases, emails, display numbers, or partial queries.\n"); }, + .reset_credits => { + try out.writeAll(" Consumes the available reset credit with the earliest expiry.\n"); + try out.writeAll(" The stored snapshot is not rewritten; run `codex-auth list --api` to reconcile it.\n"); + }, .alias => { try out.writeAll(" Alias targets can be aliases, emails, display numbers, or partial queries.\n"); try out.writeAll(" New aliases cannot be empty or only digits.\n"); diff --git a/src/cli/list_details.zig b/src/cli/list_details.zig index 6995516f..182b77b6 100644 --- a/src/cli/list_details.zig +++ b/src/cli/list_details.zig @@ -30,7 +30,7 @@ pub fn computeWidths( table_layout.columnSpec(.rate_5h, @max(widths.rate_5h, "5H".len)), table_layout.columnSpec(.rate_week, @max(widths.rate_week, "WEEKLY".len)), table_layout.columnSpec(.auth_expire, @max("AUTH EXPIRE".len, 16)), - table_layout.columnSpec(.last, @max(widths.last, "LAST".len)), + table_layout.columnSpec(.last, @max(widths.last, "LAST ACTIVITY".len)), }; var bounded = [_]usize{0} ** column_count; table_layout.allocateColumns(.{ @@ -116,7 +116,7 @@ fn headerFor(id: table_layout.ColumnId, width: usize) []const u8 { .reset_credits => if (width >= "RESET CREDITS".len) "RESET CREDITS" else if (width >= "RESET".len) "RESET" else if (width >= "RC".len) "RC" else "R", .rate_5h => "5H", .rate_week => "WEEKLY", - .last => "LAST", + .last => if (width >= "LAST ACTIVITY".len) "LAST ACTIVITY" else "LAST", .auth_expire => if (width >= "AUTH EXPIRE".len) "AUTH EXPIRE" else if (width >= "AUTH EXP".len) "AUTH EXP" else if (width >= "AUTH".len) "AUTH" else "AE"[0..@min(width, "AE".len)], .plan_expire => if (width >= "PLAN EXPIRE".len) "PLAN EXPIRE" else if (width >= "PLAN EXP".len) "PLAN EXP" else if (width >= "PLAN".len) "PLAN" else "PE"[0..@min(width, "PE".len)], }; diff --git a/src/cli/live_tui.zig b/src/cli/live_tui.zig index 5409704b..f770cfc3 100644 --- a/src/cli/live_tui.zig +++ b/src/cli/live_tui.zig @@ -17,38 +17,6 @@ pub fn mouseWheelRows(max_rows: usize) usize { return @min(max_mouse_wheel_rows, @max(min_mouse_wheel_rows, max_rows / 3)); } -test "mouse wheel step scales with visible rows" { - try std.testing.expectEqual(@as(usize, 6), mouseWheelRows(1)); - try std.testing.expectEqual(@as(usize, 6), mouseWheelRows(18)); - try std.testing.expectEqual(@as(usize, 10), mouseWheelRows(30)); - try std.testing.expectEqual(@as(usize, 12), mouseWheelRows(80)); -} - -test "list viewport keys keep paging and accept alternate-scroll wheel arrows" { - var viewport_start: usize = 9; - const row_count: usize = 105; - const max_rows: usize = 20; - const wheel_rows: usize = mouseWheelRows(max_rows); - - try std.testing.expect(applyListViewportKey(row_count, max_rows, &viewport_start, wheel_rows, .move_up)); - try std.testing.expectEqual(@as(usize, 3), viewport_start); - try std.testing.expect(applyListViewportKey(row_count, max_rows, &viewport_start, wheel_rows, .move_down)); - try std.testing.expectEqual(@as(usize, 9), viewport_start); - try std.testing.expect(!applyListViewportKey(row_count, max_rows, &viewport_start, wheel_rows, .{ .byte = 'j' })); - try std.testing.expectEqual(@as(usize, 9), viewport_start); - try std.testing.expect(!applyListViewportKey(row_count, max_rows, &viewport_start, wheel_rows, .{ .byte = 'k' })); - try std.testing.expectEqual(@as(usize, 9), viewport_start); - - try std.testing.expect(applyListViewportKey(row_count, max_rows, &viewport_start, wheel_rows, .page_down)); - try std.testing.expectEqual(@as(usize, 29), viewport_start); - try std.testing.expect(applyListViewportKey(row_count, max_rows, &viewport_start, wheel_rows, .page_up)); - try std.testing.expectEqual(@as(usize, 9), viewport_start); - try std.testing.expect(applyListViewportKey(row_count, max_rows, &viewport_start, wheel_rows, .home)); - try std.testing.expectEqual(@as(usize, 0), viewport_start); - try std.testing.expect(applyListViewportKey(row_count, max_rows, &viewport_start, wheel_rows, .end)); - try std.testing.expectEqual(@as(usize, 85), viewport_start); -} - pub fn nowSecond() i64 { return std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); } @@ -132,9 +100,46 @@ pub fn listViewport( row_count: usize, fixed_lines: usize, viewport_start: *usize, +) render.LiveListViewport { + return listViewportFollowing(terminal_rows, row_count, fixed_lines, viewport_start, null); +} + +/// Keeps a target body line visible while it is being operated on. +/// +/// `target_row` and the returned start share `renderListBodyViewport`'s index +/// basis: lines of the rendered body after its column header, which include any +/// spliced rail lines. That is deliberately not `rows.items`: the live list body +/// does not render group header rows at all. +pub fn followViewportStart( + row_count: usize, + max_rows: usize, + current_start: usize, + target_row: ?usize, +) usize { + const start = render.clampLiveViewportStart(row_count, max_rows, current_start); + const target = target_row orelse return start; + if (max_rows == 0 or row_count == 0) return start; + var followed = start; + if (target < followed) { + followed = target; + } else if (target >= followed + max_rows) { + followed = target - max_rows + 1; + } + return render.clampLiveViewportStart(row_count, max_rows, followed); +} + +/// Like `listViewport`, but scrolls the given body line into view first. The +/// reset credits rail uses this so a redeem can never be confirmed on a row the +/// user cannot see. +pub fn listViewportFollowing( + terminal_rows: usize, + row_count: usize, + fixed_lines: usize, + viewport_start: *usize, + target_row: ?usize, ) render.LiveListViewport { const max_rows = maxTableRows(terminal_rows, fixed_lines); - viewport_start.* = render.clampLiveViewportStart(row_count, max_rows, viewport_start.*); + viewport_start.* = followViewportStart(row_count, max_rows, viewport_start.*, target_row); return .{ .start_row = viewport_start.*, .max_rows = max_rows, diff --git a/src/cli/live_view.zig b/src/cli/live_view.zig index 6069677b..1423f8bc 100644 --- a/src/cli/live_view.zig +++ b/src/cli/live_view.zig @@ -9,6 +9,78 @@ const style = @import("style.zig"); const picker = @import("picker.zig"); const tui_mod = @import("tui.zig"); const live_tui = @import("live_tui.zig"); +const reset_credits_panel = @import("reset_credits_panel.zig"); +const reset_credits_api = @import("../api/reset_credits.zig"); +const registry = @import("../registry/root.zig"); +const api_usage = @import("../api/usage.zig"); + +/// Indentation of the account column in the rendered table, so the reset +/// credits rail nests under the row it belongs to. +fn panelIndentForBody(body: []const u8) usize { + const line_end = std.mem.indexOfScalar(u8, body, '\n') orelse body.len; + const header = body[0..line_end]; + var column: usize = 0; + var index: usize = 0; + while (index < header.len) { + if (header[index] == 0x1b) { + // Skip a CSI escape sequence so the scan counts display columns. + index += 1; + if (index < header.len and header[index] == '[') { + index += 1; + while (index < header.len and (header[index] < 0x40 or header[index] > 0x7e)) : (index += 1) {} + if (index < header.len) index += 1; + } + continue; + } + if (header[index] == '|') return column; + column += 1; + index += 1; + } + return 0; +} + +/// Refreshes one account's usage snapshot after a successful consume so the +/// 5H/WEEKLY cells and the remaining card list reflect the reset immediately. +fn refreshAccountUsageAfterReset( + allocator: std.mem.Allocator, + reg: *registry.Registry, + codex_home: []const u8, + account_key: []const u8, +) void { + const account_idx = registry.findAccountIndexByAccountKey(reg, account_key) orelse return; + const auth_path = registry.accountAuthPath(allocator, codex_home, account_key) catch return; + defer allocator.free(auth_path); + const fetched = api_usage.fetchUsageForAuthPathDetailed(allocator, auth_path) catch return; + const snapshot = fetched.snapshot orelse return; + const rec = ®.accounts.items[account_idx]; + if (rec.last_usage) |*old| registry.freeRateLimitSnapshot(allocator, old); + rec.last_usage = snapshot; + rec.last_usage_at = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); + if (fetched.access_token_expires_at) |expires_at| rec.access_token_expires_at = expires_at; +} + +fn alreadyConsumed(consumed_ids: []const []u8, credit_id: []const u8) bool { + for (consumed_ids) |id| { + if (std.mem.eql(u8, id, credit_id)) return true; + } + return false; +} + +/// Approves the redeem queued by a second `⏎`. `approve` drops the armed +/// confirmation whether or not the identity matches, so every attempt — +/// including a retry after a failed request — needs a fresh confirmation on the +/// credit that is actually rendered under the cursor. +fn consumeApproved( + allocator: std.mem.Allocator, + panel: *reset_credits_panel.Selection, + account_key: ?[]const u8, + credit_id: ?[]const u8, +) bool { + if (!panel.valid) return false; + const key = account_key orelse return false; + const id = credit_id orelse return false; + return panel.approve(allocator, .{ .account_key = key, .credit_id = id }); +} pub const SwitchSelectionDisplay = selection.SwitchSelectionDisplay; pub const OwnedSwitchSelectionDisplay = selection.OwnedSwitchSelectionDisplay; @@ -259,6 +331,25 @@ pub fn viewAccountsWithLiveUpdates( var body: std.Io.Writer.Allocating = .init(allocator); defer body.deinit(); + var credits_panel: std.Io.Writer.Allocating = .init(allocator); + defer credits_panel.deinit(); + var credit_selection: reset_credits_panel.Selection = .{}; + var data_row_count: usize = 0; + var pending_consume: bool = false; + var selected_credit_id: ?[]u8 = null; + var selected_account_key: ?[]u8 = null; + var redeem_key: ?[]u8 = null; + var redeem_credit_id: ?[]u8 = null; + var consumed_ids: std.ArrayList([]u8) = .empty; + defer { + credit_selection.deinit(allocator); + for (consumed_ids.items) |id| allocator.free(id); + consumed_ids.deinit(allocator); + if (selected_credit_id) |id| allocator.free(id); + if (selected_account_key) |key| allocator.free(key); + if (redeem_key) |key| allocator.free(key); + if (redeem_credit_id) |id| allocator.free(id); + } while (true) { if (try controller.maybe_take_updated_display(controller.context)) |updated| { @@ -266,6 +357,7 @@ pub fn viewAccountsWithLiveUpdates( current_display = updated; needs_render = true; rows_cache.invalidate(allocator); + credit_selection.clearSelection(allocator); } const now_second = live_tui.nowSecond(); @@ -279,14 +371,87 @@ pub fn viewAccountsWithLiveUpdates( body.clearRetainingCapacity(); var styled_body = style.StyledWriter.init(&body.writer, use_color); try render.renderListBody(allocator, &styled_body, ¤t_display.reg, rows.items, @max(@as(usize, 2), indexWidth(rows.selectable_row_indices.len)), rows.widths, tui.terminalCols()); - rendered_row_count = std.mem.count(u8, body.written(), "\n") - 1; + var body_text: []const u8 = body.written(); + var spliced_panel: ?[]u8 = null; + defer if (spliced_panel) |panel_buf| allocator.free(panel_buf); + var available_cards: []reset_credits_panel.AvailableCard = &.{}; + defer if (available_cards.len > 0) allocator.free(available_cards); + const account_count = current_display.reg.accounts.items.len; + const data_rows = render.liveDataRowCount(rows.items, account_count); + data_row_count = data_rows; + credit_selection.clampRow(allocator, data_rows); + var panel_account: ?*registry.AccountRecord = null; + if (credit_selection.expanded and data_rows > 0) { + // The cursor is a rendered data-row index; group header rows are + // not drawn, so map it back before touching rows.items. + if (render.liveRowIndexForDataIndex(rows.items, account_count, credit_selection.row_cursor)) |row_idx| { + if (rows.items[row_idx].account_index) |account_idx| { + panel_account = ¤t_display.reg.accounts.items[account_idx]; + } + } + } + if (panel_account) |account| { + available_cards = try reset_credits_panel.collectAvailable( + allocator, + account, + consumed_ids.items, + ); + credit_selection.updateCards(allocator, available_cards.len); + // Derive the shown identity once and use it for the hint, the + // armed check, and the owned copies, so the re-confirm hint can + // only ever appear on the credit a second Enter would redeem. + const shown_identity: ?reset_credits_panel.Identity = if (available_cards.len > 0) + .{ + .account_key = account.account_key, + .credit_id = available_cards[credit_selection.card_cursor].id, + } + else + null; + credits_panel.clearRetainingCapacity(); + _ = try reset_credits_panel.render( + allocator, + &credits_panel.writer, + available_cards, + tui.terminalCols(), + now_second, + credit_selection.card_cursor, + credit_selection.isPendingFor(shown_identity), + credit_selection.status, + panelIndentForBody(body_text), + ); + spliced_panel = try reset_credits_panel.spliceAfterDataLine( + allocator, + body_text, + credit_selection.row_cursor, + credits_panel.written(), + ); + body_text = spliced_panel.?; + if (selected_credit_id) |id| allocator.free(id); + selected_credit_id = if (shown_identity) |identity| + try allocator.dupe(u8, identity.credit_id) + else + null; + if (selected_account_key) |key| allocator.free(key); + selected_account_key = if (shown_identity) |identity| + try allocator.dupe(u8, identity.account_key) + else + null; + } else { + credit_selection.clearSelection(allocator); + if (selected_credit_id) |id| allocator.free(id); + selected_credit_id = null; + if (selected_account_key) |key| allocator.free(key); + selected_account_key = null; + } + rendered_row_count = std.mem.count(u8, body_text, "\n") -| 1; const status_line = try controller.build_status_line(controller.context, allocator, current_display.borrowed()); defer allocator.free(status_line); - const viewport = live_tui.listViewport( + const viewport = live_tui.listViewportFollowing( tui.terminalRows(), rendered_row_count, live_tui.listFixedLines(status_line), &viewport_start, + if (credit_selection.expanded) credit_selection.row_cursor else null, ); var bounded_viewport = viewport; bounded_viewport.max_cols = tui.terminalCols(); @@ -295,7 +460,7 @@ pub fn viewAccountsWithLiveUpdates( var styled_frame = style.StyledWriter.init(&frame.writer, use_color); render.renderListBodyViewport( &styled_frame, - body.written(), + body_text, status_line, bounded_viewport, ) catch |err| return mapTuiOutputError(err); @@ -317,19 +482,186 @@ pub fn viewAccountsWithLiveUpdates( const wheel_rows = live_tui.mouseWheelRows(max_rows); for (key_buf[0..key_count]) |key| { + // A queued redeem is a committed action: stop consuming + // this key batch, otherwise a `j` burst behind `⏎⏎` would + // move the rail cursor and silently cancel the approval. + if (pending_consume) break; + if (credit_selection.expanded) { + switch (key) { + .move_up, .keyboard_up => { + credit_selection.moveCard(allocator, .up); + needs_render = true; + continue; + }, + .move_down, .keyboard_down => { + credit_selection.moveCard(allocator, .down); + needs_render = true; + continue; + }, + .enter => { + if (selected_account_key) |account_key| { + if (selected_credit_id) |credit_id| { + switch (credit_selection.confirm(allocator, .{ + .account_key = account_key, + .credit_id = credit_id, + })) { + .consume => { + pending_consume = true; + needs_render = true; + }, + .armed => needs_render = true, + .ignored => {}, + .out_of_memory => { + try credit_selection.setStatus( + allocator, + "credit selection unavailable (out of memory)", + ); + needs_render = true; + }, + } + } + } + continue; + }, + else => {}, + } + } if (live_tui.applyListViewportKey(rendered_row_count, max_rows, &viewport_start, wheel_rows, key)) { needs_render = true; continue; } switch (key) { - .quit => return, + .quit => { + if (credit_selection.expanded) { + credit_selection.close(allocator); + needs_render = true; + continue; + } + return; + }, .redraw => needs_render = true, .byte => |ch| { - if (isQuitKey(ch)) return; + if (isQuitKey(ch)) { + // `q` and `esc` are advertised as equivalent + // quit keys, so while the rail is open both + // only close it instead of dropping a + // confirmation and ending the session. + if (credit_selection.expanded) { + credit_selection.close(allocator); + needs_render = true; + continue; + } + return; + } + if (ch == 'c') { + credit_selection.toggleExpanded(allocator); + needs_render = true; + continue; + } + if (ch == 'j') { + credit_selection.moveRow(allocator, .down, data_row_count); + needs_render = true; + continue; + } + if (ch == 'k') { + credit_selection.moveRow(allocator, .up, data_row_count); + needs_render = true; + continue; + } }, else => {}, } } + if (pending_consume) { + pending_consume = false; + if (consumeApproved(allocator, &credit_selection, selected_account_key, selected_credit_id)) { + // Reuse the idempotency key while retrying the same + // credit: the backend deduplicates repeated redeems. + if (redeem_credit_id == null or !std.mem.eql(u8, redeem_credit_id.?, selected_credit_id.?)) { + if (redeem_key) |key| allocator.free(key); + if (redeem_credit_id) |id| allocator.free(id); + redeem_key = reset_credits_api.generateIdempotencyKeyAlloc(allocator) catch null; + redeem_credit_id = allocator.dupe(u8, selected_credit_id.?) catch null; + } + if (redeem_key) |key| { + const codex_home = registry.resolveCodexHome(allocator) catch |err| { + try credit_selection.setStatus(allocator, @errorName(err)); + needs_render = true; + continue; + }; + defer allocator.free(codex_home); + var result = reset_credits_api.consumeCreditForAccount( + allocator, + codex_home, + selected_account_key.?, + key, + selected_credit_id.?, + ) catch |err| reset_credits_api.ConsumeResult{ .outcome = .request_error, .error_name = try allocator.dupe(u8, @errorName(err)) }; + defer result.deinit(allocator); + switch (result.outcome) { + .reset, .already_redeemed => { + var already_consumed = false; + for (consumed_ids.items) |id| { + if (std.mem.eql(u8, id, selected_credit_id.?)) { + already_consumed = true; + break; + } + } + if (!already_consumed) { + const id_copy = try allocator.dupe(u8, selected_credit_id.?); + try consumed_ids.append(allocator, id_copy); + } + if (redeem_key) |k| allocator.free(k); + redeem_key = null; + if (redeem_credit_id) |c| allocator.free(c); + redeem_credit_id = null; + try credit_selection.setStatus(allocator, "✓ reset credit used"); + refreshAccountUsageAfterReset(allocator, ¤t_display.reg, codex_home, selected_account_key.?); + registry.saveRegistry(allocator, codex_home, ¤t_display.reg) catch {}; + rows_cache.invalidate(allocator); + }, + .no_credit => { + if (!alreadyConsumed(consumed_ids.items, selected_credit_id.?)) { + const id_copy = try allocator.dupe(u8, selected_credit_id.?); + try consumed_ids.append(allocator, id_copy); + } + if (redeem_key) |k| allocator.free(k); + redeem_key = null; + if (redeem_credit_id) |c| allocator.free(c); + redeem_credit_id = null; + try credit_selection.setStatus(allocator, "✗ reset credit is no longer available · refresh to see current resets"); + }, + .nothing_to_reset => { + try credit_selection.setStatus(allocator, "reset not needed — usage does not need a reset right now"); + }, + .unexpected_response => { + if (result.status_code) |code| { + const text = try std.fmt.allocPrint(allocator, "✗ use failed (HTTP {d}, unexpected response)", .{code}); + defer allocator.free(text); + try credit_selection.setStatus(allocator, text); + } else { + try credit_selection.setStatus(allocator, "✗ use failed (unexpected response)"); + } + }, + .http_error => { + if (result.status_code) |code| { + const text = try std.fmt.allocPrint(allocator, "✗ use failed (HTTP {d})", .{code}); + defer allocator.free(text); + try credit_selection.setStatus(allocator, text); + } else { + try credit_selection.setStatus(allocator, "use failed"); + } + }, + .request_error => { + try credit_selection.setStatus(allocator, result.error_name orelse "use failed"); + }, + } + } else { + try credit_selection.setStatus(allocator, "✗ use failed (idempotency key unavailable)"); + } + needs_render = true; + } + } } }, } diff --git a/src/cli/output.zig b/src/cli/output.zig index c2a4f999..eee3a8bc 100644 --- a/src/cli/output.zig +++ b/src/cli/output.zig @@ -397,6 +397,62 @@ pub fn printAliasCleared(rec: *const registry.AccountRecord, old_alias: []const try out.flush(); } +pub const ResetRemainingEntry = struct { + title: ?[]const u8, + expires_local: ?[]const u8, +}; + +/// Prints a `reset` command failure to stderr with the standard error prefix. +pub fn printResetError(comptime fmt: []const u8, args: anytype) !void { + var stderr: io_util.Stderr = undefined; + stderr.init(); + const out = stderr.out(); + const use_color = stderr.color_enabled; + try writeErrorPrefixTo(out, use_color); + try out.writeAll(" "); + try out.print(fmt, args); + try out.writeAll("\n"); + try out.flush(); +} + +/// Prints the consumed reset credit as one compact line. +pub fn printResetConsumed(title: ?[]const u8, expires_local: ?[]const u8, already_used: bool) !void { + var stdout: io_util.Stdout = undefined; + stdout.init(); + const out = stdout.out(); + if (already_used) { + try out.print("already used \"{s}\"", .{title orelse "(untitled)"}); + } else { + try out.print("used \"{s}\"", .{title orelse "(untitled)"}); + } + if (expires_local) |expiry| { + try out.print(" \u{00b7} expires {s}", .{expiry}); + } + try out.writeAll("\n"); + try out.flush(); +} + +/// Prints a one-line summary of the remaining reset credits: the count and +/// the card that expires next (title plus local expiry). +pub fn printResetRemaining(entries: []const ResetRemainingEntry) !void { + var stdout: io_util.Stdout = undefined; + stdout.init(); + const out = stdout.out(); + if (entries.len == 0) { + try out.writeAll("remaining: 0\n"); + } else { + const next = entries[0]; + try out.print("remaining: {d} \u{00b7} next: {s}", .{ entries.len, next.title orelse "(untitled)" }); + if (next.expires_local) |expiry| { + try out.print(" \u{00b7} {s}", .{expiry}); + } else { + try out.writeAll(" \u{00b7} -"); + } + try out.writeAll("\n"); + } + try out.flush(); +} + pub fn printInvalidRemoveSelectionError() !void { var stderr: io_util.Stderr = undefined; stderr.init(); diff --git a/src/cli/render.zig b/src/cli/render.zig index 0778dbe2..45683755 100644 --- a/src/cli/render.zig +++ b/src/cli/render.zig @@ -255,6 +255,13 @@ pub fn clampLiveViewportStart(row_count: usize, max_rows: usize, current_start: return @min(current_start, row_count - max_rows); } +/// Viewport start that keeps the selected display index visible on the screens +/// that render group header rows (switch, remove). +/// +/// Both the clamp and the resolved row index use the `rows.items` basis, which +/// matches those screens one line per row. Do not feed it the live list rail +/// cursor: that one counts body lines without header rows, and is followed with +/// `live_tui.followViewportStart` instead. pub fn liveViewportStartForDisplayIndex( rows: []const SwitchRow, selected_display_idx: ?usize, @@ -291,6 +298,11 @@ fn visibleRowRange(row_count: usize, viewport: LiveListViewport) VisibleRowRange }; } +/// Maps a selectable display index to its index in `rows`. +/// +/// Index basis: `rows.items`, for the views that DO render group header rows +/// (`renderSwitchScreen`, `renderRemoveScreen`). The live list body skips those +/// rows entirely, so its rail cursor must use `liveRowIndexForDataIndex` instead. fn rowIndexForDisplayIndex(rows: []const SwitchRow, selected_display_idx: usize) ?usize { var display_idx: usize = 0; for (rows, 0..) |row, row_idx| { @@ -301,6 +313,9 @@ fn rowIndexForDisplayIndex(rows: []const SwitchRow, selected_display_idx: usize) return null; } +/// Number of selectable rows in a `rows` prefix, on the same `rows.items` basis +/// as `rowIndexForDisplayIndex`. Not the live list body line count, which is +/// `liveDataRowCount`. fn dataRowCount(rows: []const SwitchRow) usize { var count: usize = 0; for (rows) |row| { @@ -412,6 +427,37 @@ fn writeSwitchRow( displayed_counter.* += 1; } +/// Single source of truth for which rows the live list body renders. The rail +/// cursor indexes those rendered lines, so mapping and rendering share one +/// predicate. Views that DO draw group header rows (switch, remove) use the +/// `rows.items`-based helpers above instead; the two bases are not interchangeable. +pub fn isRenderedLiveRow(row: SwitchRow, account_count: usize) bool { + if (row.is_header) return false; + const account_idx = row.account_index orelse return false; + return account_idx < account_count; +} + +/// Number of data rows `renderListBody` emits for these rows. +pub fn liveDataRowCount(rows: []const SwitchRow, account_count: usize) usize { + var count: usize = 0; + for (rows) |row| { + if (isRenderedLiveRow(row, account_count)) count += 1; + } + return count; +} + +/// Maps a rendered data-row index back to its index in `rows`, skipping the +/// rows `renderListBody` does not draw (group headers and stale indices). +pub fn liveRowIndexForDataIndex(rows: []const SwitchRow, account_count: usize, data_index: usize) ?usize { + var rendered: usize = 0; + for (rows, 0..) |row, row_idx| { + if (!isRenderedLiveRow(row, account_count)) continue; + if (rendered == data_index) return row_idx; + rendered += 1; + } + return null; +} + /// Render the compact live account table. pub fn renderListBody( allocator: std.mem.Allocator, @@ -429,9 +475,8 @@ pub fn renderListBody( try details.writeHeader(writer, column_widths); var displayed_counter: usize = 0; for (rows) |row| { - if (row.is_header) continue; - const idx = row.account_index orelse continue; - if (idx >= reg.accounts.items.len) continue; + if (!isRenderedLiveRow(row, reg.accounts.items.len)) continue; + const idx = row.account_index.?; displayed_counter += 1; const account = reg.accounts.items[idx]; var number_buf: [32]u8 = undefined; diff --git a/src/cli/reset_credits_panel.zig b/src/cli/reset_credits_panel.zig new file mode 100644 index 00000000..aaa8acde --- /dev/null +++ b/src/cli/reset_credits_panel.zig @@ -0,0 +1,358 @@ +const std = @import("std"); +const registry = @import("../registry/root.zig"); +const rows = @import("rows.zig"); +const timestamp = @import("../time/timestamp.zig"); +const text_width = @import("../tui/text_width.zig"); + +const available_status = "available"; + +pub const AvailableCard = struct { + id: []const u8, + title: ?[]const u8, + expires_at: ?[]const u8, + epoch: ?i64, +}; + +/// Direction of a cursor move inside the reset credits panel. +pub const Direction = enum { up, down }; + +/// The exact credit a confirmation refers to. Redemption is irreversible, so +/// the pending state is bound to this pair instead of a bare flag. +pub const Identity = struct { + account_key: []const u8, + credit_id: []const u8, +}; + +/// Result of pressing `⏎` while the panel is open. +pub const ConfirmStep = enum { + /// The credit is now armed; the caller must render the re-confirm hint. + armed, + /// The already armed credit was confirmed again; the caller may redeem it + /// after `approve` accepts the same identity. + consume, + /// Nothing is consumable: the panel is closed or the cursor row has no + /// available credit. + ignored, + /// The armed identity could not be recorded; nothing was armed. + out_of_memory, +}; + +/// Selection state of the live reset credits panel. +/// +/// Every cursor change goes through these methods so a pending confirmation +/// can never outlive the credit it was armed for: moving the credit cursor, +/// moving to another account row, collapsing the panel, or losing the credit +/// list under the cursor all disarm it, and the armed identity is compared +/// again before any redeem is sent. +pub const Selection = struct { + pub const Armed = struct { + account_key: []u8, + credit_id: []u8, + }; + + expanded: bool = false, + row_cursor: usize = 0, + card_cursor: usize = 0, + card_count: usize = 0, + valid: bool = false, + /// The credit a confirmation is pending for; `null` means nothing is + /// pending. Owning the copies keeps the invariant local to this struct. + armed: ?Armed = null, + /// Status line rendered inside the rail, owned by the selection so it + /// cannot describe a credit from another account row. + status: ?[]u8 = null, + + pub fn deinit(self: *Selection, allocator: std.mem.Allocator) void { + self.clearArmed(allocator); + self.clearStatus(allocator); + } + + /// True only when `identity` is the exact credit the confirmation is armed + /// for. The rail draws its re-confirm hint from this, so the hint and the + /// credit a second Enter would redeem can never disagree. + pub fn isPendingFor(self: *Selection, identity: ?Identity) bool { + const armed = self.armed orelse return false; + const current = identity orelse return false; + return std.mem.eql(u8, armed.account_key, current.account_key) and + std.mem.eql(u8, armed.credit_id, current.credit_id); + } + + pub fn toggleExpanded(self: *Selection, allocator: std.mem.Allocator) void { + self.expanded = !self.expanded; + if (!self.expanded) self.close(allocator); + } + + /// Closes the rail: `esc` and `q` both stop at the rail while it is open, + /// so neither can leave a confirmation armed or quit the session. + pub fn close(self: *Selection, allocator: std.mem.Allocator) void { + self.expanded = false; + self.clearArmed(allocator); + self.clearStatus(allocator); + } + + pub fn moveRow(self: *Selection, allocator: std.mem.Allocator, direction: Direction, row_count: usize) void { + const next = movedIndex(self.row_cursor, direction, row_count) orelse return; + self.row_cursor = next; + self.clearSelection(allocator); + self.clearStatus(allocator); + } + + pub fn moveCard(self: *Selection, allocator: std.mem.Allocator, direction: Direction) void { + const next = movedIndex(self.card_cursor, direction, self.card_count) orelse return; + self.card_cursor = next; + self.clearArmed(allocator); + } + + /// Keeps the row cursor inside the rendered table after a refresh. + pub fn clampRow(self: *Selection, allocator: std.mem.Allocator, row_count: usize) void { + if (self.row_cursor < row_count) return; + self.row_cursor = if (row_count == 0) 0 else row_count - 1; + self.clearSelection(allocator); + } + + /// Publishes the credit list rendered for the cursor row. The highlight + /// stays on the same card when it still exists; when the list shrinks the + /// confirmation is dropped because the highlighted card changed. + pub fn updateCards(self: *Selection, allocator: std.mem.Allocator, count: usize) void { + self.card_count = count; + if (self.card_cursor >= count) { + self.card_cursor = if (count == 0) 0 else count - 1; + self.clearArmed(allocator); + } + self.valid = count > 0; + if (!self.valid) self.clearArmed(allocator); + } + + /// The cursor row has no consumable credit: keep the row cursor, drop the + /// card highlight, the armed confirmation, and any status line. + pub fn clearSelection(self: *Selection, allocator: std.mem.Allocator) void { + self.card_cursor = 0; + self.card_count = 0; + self.valid = false; + self.clearArmed(allocator); + self.clearStatus(allocator); + } + + /// Two-step confirmation for the highlighted credit. + pub fn confirm(self: *Selection, allocator: std.mem.Allocator, identity: Identity) ConfirmStep { + if (!self.expanded or !self.valid) return .ignored; + if (self.armed) |entry| { + if (std.mem.eql(u8, entry.account_key, identity.account_key) and + std.mem.eql(u8, entry.credit_id, identity.credit_id)) + { + return .consume; + } + self.clearArmed(allocator); + } + const account_key = allocator.dupe(u8, identity.account_key) catch return .out_of_memory; + const credit_id = allocator.dupe(u8, identity.credit_id) catch { + allocator.free(account_key); + return .out_of_memory; + }; + self.armed = .{ .account_key = account_key, .credit_id = credit_id }; + return .armed; + } + + /// Accepts the redeem for `identity` exactly once: returns true only when + /// the armed confirmation matches it, and always drops the armed state so + /// a second redeem needs a fresh confirmation. + pub fn approve(self: *Selection, allocator: std.mem.Allocator, identity: Identity) bool { + const entry = self.armed orelse return false; + const matches = std.mem.eql(u8, entry.account_key, identity.account_key) and + std.mem.eql(u8, entry.credit_id, identity.credit_id); + self.clearArmed(allocator); + return matches; + } + + pub fn setStatus(self: *Selection, allocator: std.mem.Allocator, text: []const u8) !void { + const copy = try allocator.dupe(u8, text); + if (self.status) |old| allocator.free(old); + self.status = copy; + } + + pub fn clearStatus(self: *Selection, allocator: std.mem.Allocator) void { + if (self.status) |old| { + allocator.free(old); + self.status = null; + } + } + + fn clearArmed(self: *Selection, allocator: std.mem.Allocator) void { + if (self.armed) |entry| { + allocator.free(entry.account_key); + allocator.free(entry.credit_id); + self.armed = null; + } + } +}; + +fn movedIndex(cursor: usize, direction: Direction, count: usize) ?usize { + switch (direction) { + .up => return if (cursor > 0) cursor - 1 else null, + .down => return if (count == 0 or cursor + 1 >= count) null else cursor + 1, + } +} + +/// Available (unconsumed) cards for one account, sorted by expiry ascending +/// (no-expiry last). `consumed_ids` filters cards already used in this session. +pub fn collectAvailable( + allocator: std.mem.Allocator, + account: *const registry.AccountRecord, + consumed_ids: []const []const u8, +) ![]AvailableCard { + var cards: std.ArrayList(AvailableCard) = .empty; + errdefer cards.deinit(allocator); + + const usage = account.last_usage orelse return cards.toOwnedSlice(allocator); + const details = usage.reset_credit orelse return cards.toOwnedSlice(allocator); + + for (details) |credit| { + if (!std.mem.eql(u8, credit.status, available_status)) continue; + var consumed = false; + for (consumed_ids) |used| { + if (std.mem.eql(u8, used, credit.id)) { + consumed = true; + break; + } + } + if (consumed) continue; + try cards.append(allocator, .{ + .id = credit.id, + .title = credit.title, + .expires_at = credit.expires_at, + .epoch = timestamp.parseIso8601(credit.expires_at orelse ""), + }); + } + std.mem.sort(AvailableCard, cards.items, {}, cardLessThan); + return cards.toOwnedSlice(allocator); +} + +/// Renders the RESET CREDITS rail block. Lines are bounded to `max_cols` +/// and '\n'-terminated. `card_cursor` marks the selected credit; the +/// selected line appends the two-step confirm hint when `confirm_pending`. +/// `status_line` surfaces the result of the last action. +pub fn render( + allocator: std.mem.Allocator, + writer: *std.Io.Writer, + cards: []const AvailableCard, + max_cols: usize, + now: i64, + card_cursor: usize, + confirm_pending: bool, + status_line: ?[]const u8, + indent: usize, +) !usize { + var lines: usize = 0; + const budget = if (max_cols > indent + 2) max_cols - indent - 2 else 0; + var header_buf: [128]u8 = undefined; + const header = std.fmt.bufPrint(&header_buf, "Available {d} · ⏎ select · esc close", .{cards.len}) catch "Available"; + try writeIndent(writer, indent); + try writer.writeAll("┌ "); + try writeBounded(writer, header, budget); + try writer.writeByte('\n'); + lines += 1; + + if (cards.len == 0) { + try writeIndent(writer, indent); + try writer.writeAll("│ "); + try writeBounded(writer, "no available reset credits · run `codex-auth list --api` to refresh", budget); + try writer.writeByte('\n'); + lines += 1; + } else { + for (cards, 0..) |card, index| { + const selected = index == card_cursor; + var line: std.Io.Writer.Allocating = .init(allocator); + defer line.deinit(); + try line.writer.writeAll(if (selected) "▸ " else " "); + try line.writer.writeAll(card.title orelse "(untitled)"); + if (card.epoch) |epoch| { + const local = try rows.formatTimestampAlloc(allocator, epoch); + defer allocator.free(local); + const rel = try relativeIn(allocator, epoch, now); + defer allocator.free(rel); + const compact = if (local.len >= 16) local[5..16] else local; + try line.writer.print(" · {s} · {s}", .{ compact, rel }); + } else { + try line.writer.writeAll(" · -"); + } + if (selected and confirm_pending) try line.writer.writeAll(" · ⏎ again to use"); + try writeIndent(writer, indent); + try writer.writeAll("│ "); + try writeBounded(writer, line.written(), budget); + try writer.writeByte('\n'); + lines += 1; + } + } + + if (status_line) |message| { + try writeIndent(writer, indent); + try writer.writeAll("│ "); + try writeBounded(writer, message, budget); + try writer.writeByte('\n'); + lines += 1; + } + + try writeIndent(writer, indent); + try writer.writeAll("└\n"); + lines += 1; + return lines; +} + +fn writeIndent(writer: *std.Io.Writer, indent: usize) !void { + var index: usize = 0; + while (index < indent) : (index += 1) { + try writer.writeByte(' '); + } +} + +/// Splices `panel` into `body` (a '\n'-separated table render: line 0 is the +/// header, data lines follow) immediately after the given 0-based data line. +/// The terminating newline of `body` must not become a phantom data row. +pub fn spliceAfterDataLine(allocator: std.mem.Allocator, body: []const u8, data_line: usize, panel: []const u8) ![]u8 { + var out: std.Io.Writer.Allocating = .init(allocator); + errdefer out.deinit(); + var iter = std.mem.splitScalar(u8, body, '\n'); + var index: usize = 0; + while (iter.next()) |line| : (index += 1) { + const is_terminator = line.len == 0 and iter.rest().len == 0 and index > 0; + if (!is_terminator) { + try out.writer.writeAll(line); + try out.writer.writeByte('\n'); + } + if (index == data_line + 1) { + try out.writer.writeAll(panel); + if (panel.len == 0 or panel[panel.len - 1] != '\n') try out.writer.writeByte('\n'); + } + } + const copy = try allocator.dupe(u8, out.written()); + out.deinit(); + return copy; +} + +fn cardLessThan(_: void, a: AvailableCard, b: AvailableCard) bool { + const a_epoch = a.epoch orelse std.math.maxInt(i64); + const b_epoch = b.epoch orelse std.math.maxInt(i64); + return a_epoch < b_epoch; +} + +fn relativeIn(allocator: std.mem.Allocator, target: i64, now: i64) ![]u8 { + const delta = target - now; + if (delta <= 0) return std.fmt.allocPrint(allocator, "expired", .{}); + if (delta < 60) return std.fmt.allocPrint(allocator, "in {d}s", .{delta}); + if (delta < 3600) return std.fmt.allocPrint(allocator, "in {d}m", .{@divTrunc(delta, 60)}); + if (delta < 86400) return std.fmt.allocPrint(allocator, "in {d}h", .{@divTrunc(delta, 3600)}); + return std.fmt.allocPrint(allocator, "in {d}d", .{@divTrunc(delta, 86400)}); +} + +fn writeBounded(writer: *std.Io.Writer, text: []const u8, max_cols: usize) !void { + if (text_width.displayWidth(text) <= max_cols) { + try writer.writeAll(text); + return; + } + if (max_cols <= 3) { + try writer.writeAll("..."); + return; + } + try writer.writeAll(text[0..text_width.prefixByteLength(text, max_cols - 3)]); + try writer.writeAll("..."); +} diff --git a/src/cli/root.zig b/src/cli/root.zig index 02003908..740b629e 100644 --- a/src/cli/root.zig +++ b/src/cli/root.zig @@ -8,5 +8,7 @@ pub const picker = @import("picker.zig"); pub const live = @import("live.zig"); pub const live_tui = @import("live_tui.zig"); pub const render = @import("render.zig"); +pub const reset_credits_panel = @import("reset_credits_panel.zig"); pub const rows = @import("rows.zig"); +pub const table_layout = @import("table_layout.zig"); pub const tui = @import("tui.zig"); diff --git a/src/cli/tui.zig b/src/cli/tui.zig index 045bc06a..b9dd4a00 100644 --- a/src/cli/tui.zig +++ b/src/cli/tui.zig @@ -303,9 +303,9 @@ pub fn writeRemoveTuiFooterBounded(out: *std.Io.Writer, use_color: bool, max_col pub fn listTuiFooterText(is_windows: bool) []const u8 { return if (is_windows) - "Keys: Up/Down scroll, PgUp/PgDn page, Home/End jump, Esc or q quit\n" + "Keys: Up/Down scroll, j/k row, c credits, PgUp/PgDn/Home/End page, Esc/q quit\n" else - "Keys: ↑/↓ scroll, PgUp/PgDn page, Home/End jump, Esc or q quit\n"; + "Keys: ↑/↓ scroll, j/k row, c credits, PgUp/PgDn/Home/End page, Esc/q quit\n"; } pub fn writeListTuiFooter(out: *std.Io.Writer, use_color: bool) !void { diff --git a/src/cli/types.zig b/src/cli/types.zig index f49613e8..be71104d 100644 --- a/src/cli/types.zig +++ b/src/cli/types.zig @@ -44,6 +44,9 @@ pub const RemoveOptions = struct { api_mode: ApiMode = .default, json: bool = false, }; +pub const ResetOptions = struct { + selector: []u8, +}; pub const AliasSetOptions = struct { selector: []u8, alias: []u8, @@ -81,6 +84,7 @@ pub const HelpTopic = enum { export_auth, switch_account, remove_account, + reset_credits, alias, clean, config, @@ -94,6 +98,7 @@ pub const Command = union(enum) { export_auth: ExportOptions, switch_account: SwitchOptions, remove_account: RemoveOptions, + reset_credits: ResetOptions, alias: AliasOptions, clean: CleanOptions, config: ConfigOptions, diff --git a/src/root.zig b/src/root.zig index ab15e0aa..6f027387 100644 --- a/src/root.zig +++ b/src/root.zig @@ -2,6 +2,7 @@ pub const api = struct { pub const account = @import("api/account.zig"); pub const http = @import("api/http.zig"); pub const me = @import("api/me.zig"); + pub const reset_credits = @import("api/reset_credits.zig"); pub const usage = @import("api/usage.zig"); }; @@ -34,6 +35,7 @@ pub const time = struct { pub const tui = struct { pub const display = @import("tui/display.zig"); pub const table = @import("tui/table.zig"); + pub const text_width = @import("tui/text_width.zig"); }; pub const version = @import("version.zig"); diff --git a/src/tui/text_width.zig b/src/tui/text_width.zig index 1ce92ac0..82edb717 100644 --- a/src/tui/text_width.zig +++ b/src/tui/text_width.zig @@ -30,9 +30,3 @@ fn codepointByteLength(value: []const u8) usize { _ = std.unicode.utf8Decode(value[0..len]) catch return 1; return len; } - -test "display width counts UTF-8 codepoints instead of bytes" { - try std.testing.expectEqual(@as(usize, 7), displayWidth("Рабочая")); - try std.testing.expectEqual(@as(usize, 6), prefixByteLength("Рабочая", 3)); - try std.testing.expectEqualStrings("чая", "Рабочая"[suffixStart("Рабочая", 3)..]); -} diff --git a/src/workflows/preflight.zig b/src/workflows/preflight.zig index 9e935bec..0053a65c 100644 --- a/src/workflows/preflight.zig +++ b/src/workflows/preflight.zig @@ -36,6 +36,7 @@ pub fn isHandledCliError(err: anyerror) bool { err == error.RemoveSelectionRequiresTty or err == error.InvalidRemoveSelectionInput or err == error.ImportFailed or + err == error.ResetFailed or err == error.AppLaunchConfigValidationFailed or err == error.AppIdRequired or err == error.AppIdNotFound or diff --git a/src/workflows/reset.zig b/src/workflows/reset.zig new file mode 100644 index 00000000..5437ea32 --- /dev/null +++ b/src/workflows/reset.zig @@ -0,0 +1,103 @@ +const std = @import("std"); +const cli = @import("../cli/root.zig"); +const registry = @import("../registry/root.zig"); +const reset_credits_api = @import("../api/reset_credits.zig"); +const query_mod = @import("query.zig"); + +const panel = cli.reset_credits_panel; + +pub fn handleReset(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.types.ResetOptions) !void { + var reg = try registry.loadRegistry(allocator, codex_home); + defer reg.deinit(allocator); + + var resolution = try query_mod.resolveSwitchQueryLocally(allocator, ®, opts.selector); + defer resolution.deinit(allocator); + + const account_key = switch (resolution) { + .not_found => { + try cli.output.printResetError("no account matches '{s}'.", .{opts.selector}); + return error.AccountNotFound; + }, + .direct => |key| key, + .multiple => { + try cli.output.printResetError("`{s}` matches multiple accounts; use an exact email, alias, or account key.", .{opts.selector}); + return error.ResetFailed; + }, + }; + const account_idx = registry.findAccountIndexByAccountKey(®, account_key) orelse { + try cli.output.printResetError("no account matches '{s}'.", .{opts.selector}); + return error.AccountNotFound; + }; + const rec = ®.accounts.items[account_idx]; + + const cards = try panel.collectAvailable(allocator, rec, &.{}); + defer allocator.free(cards); + if (cards.len == 0) { + try cli.output.printResetError("no available reset credits for {s}.", .{rec.email}); + return error.ResetFailed; + } + + // collectAvailable sorts by expiry ascending (no expiry last), so the + // first card is the one that expires soonest. + const card = cards[0]; + const redeem_key = try reset_credits_api.generateIdempotencyKeyAlloc(allocator); + defer allocator.free(redeem_key); + var result = try reset_credits_api.consumeCreditForAccount( + allocator, + codex_home, + rec.account_key, + redeem_key, + card.id, + ); + defer result.deinit(allocator); + + switch (result.outcome) { + .reset, .already_redeemed => { + const expires_local = if (card.epoch) |epoch| + try cli.rows.formatTimestampAlloc(allocator, epoch) + else + null; + defer if (expires_local) |text| allocator.free(text); + try cli.output.printResetConsumed(card.title, expires_local, result.outcome == .already_redeemed); + + var remaining = std.ArrayList(cli.output.ResetRemainingEntry).empty; + defer { + for (remaining.items) |entry| { + if (entry.expires_local) |text| allocator.free(text); + } + remaining.deinit(allocator); + } + for (cards[1..]) |remaining_card| { + const remaining_expiry = if (remaining_card.epoch) |epoch| + try cli.rows.formatTimestampAlloc(allocator, epoch) + else + null; + try remaining.append(allocator, .{ + .title = remaining_card.title, + .expires_local = remaining_expiry, + }); + } + try cli.output.printResetRemaining(remaining.items); + }, + .no_credit => { + try cli.output.printResetError("reset credit is no longer available; run `codex-auth list --api` to refresh.", .{}); + return error.ResetFailed; + }, + .nothing_to_reset => { + try cli.output.printResetError("usage does not need a reset right now.", .{}); + return error.ResetFailed; + }, + .unexpected_response => { + try cli.output.printResetError("reset failed: unexpected consume response (HTTP {?d}).", .{result.status_code}); + return error.ResetFailed; + }, + .http_error => { + try cli.output.printResetError("reset failed (HTTP {?d}).", .{result.status_code}); + return error.ResetFailed; + }, + .request_error => { + try cli.output.printResetError("reset failed: {s}.", .{result.error_name orelse "request error"}); + return error.ResetFailed; + }, + } +} diff --git a/src/workflows/root.zig b/src/workflows/root.zig index d5cd7adf..2362906e 100644 --- a/src/workflows/root.zig +++ b/src/workflows/root.zig @@ -23,6 +23,7 @@ const import_workflow = @import("import.zig"); const export_workflow = @import("export.zig"); const switch_workflow = @import("switch.zig"); const remove_workflow = @import("remove.zig"); +const reset_workflow = @import("reset.zig"); const alias_workflow = @import("alias.zig"); const workflow_env = @import("env.zig"); const targets = @import("targets.zig"); @@ -158,6 +159,7 @@ fn runMain(init: std.process.Init.Minimal) !void { .export_auth => |opts| try export_workflow.handleExport(allocator, codex_home.?, opts), .switch_account => |opts| try switch_workflow.handleSwitch(allocator, codex_home.?, opts), .remove_account => |opts| try remove_workflow.handleRemove(allocator, codex_home.?, opts), + .reset_credits => |opts| try reset_workflow.handleReset(allocator, codex_home.?, opts), .alias => |opts| try alias_workflow.handleAlias(allocator, codex_home.?, opts), .clean => |opts| try clean_workflow.handleClean(allocator, codex_home.?, opts), } diff --git a/tests/api_usage_test.zig b/tests/api_usage_test.zig index b8e06da4..717d5f1d 100644 --- a/tests/api_usage_test.zig +++ b/tests/api_usage_test.zig @@ -1,6 +1,7 @@ const std = @import("std"); const app_runtime = @import("codex_auth").core.runtime; const registry = @import("codex_auth").registry; +const reset_credits_api = @import("codex_auth").api.reset_credits; const usage_api = @import("codex_auth").api.usage; test "parse usage api response maps live usage windows and plan" { @@ -264,3 +265,68 @@ test "parse 402 usage error response uses detail code as error code fallback" { const code = usage_api.parseNonSuccessErrorCode(std.testing.allocator, 402, body) orelse return error.TestExpectedEqual; try std.testing.expectEqualStrings("deactivated_workspace", code.text()); } + +test "consume outcome maps the backend code field" { + const gpa = std.testing.allocator; + try std.testing.expectEqual( + reset_credits_api.ConsumeOutcome.reset, + reset_credits_api.parseConsumeOutcome(gpa, "{\"code\":\"reset\",\"windows_reset\":2}"), + ); + try std.testing.expectEqual( + reset_credits_api.ConsumeOutcome.already_redeemed, + reset_credits_api.parseConsumeOutcome(gpa, "{\"code\":\"already_redeemed\",\"windows_reset\":1}"), + ); + try std.testing.expectEqual( + reset_credits_api.ConsumeOutcome.nothing_to_reset, + reset_credits_api.parseConsumeOutcome(gpa, "{\"code\":\"nothing_to_reset\"}"), + ); + try std.testing.expectEqual( + reset_credits_api.ConsumeOutcome.no_credit, + reset_credits_api.parseConsumeOutcome(gpa, "{\"code\":\"no_credit\"}"), + ); + try std.testing.expectEqual( + reset_credits_api.ConsumeOutcome.unexpected_response, + reset_credits_api.parseConsumeOutcome(gpa, "{}"), + ); + try std.testing.expectEqual( + reset_credits_api.ConsumeOutcome.unexpected_response, + reset_credits_api.parseConsumeOutcome(gpa, "not json"), + ); +} + +test "consume body json-encodes the server-provided credit id" { + const gpa = std.testing.allocator; + const body = try reset_credits_api.buildConsumeBodyAlloc( + gpa, + "3f2b7c1e-0000-4000-8000-abcdef012345", + "credit\"1 \\ \u{7}\n", + ); + defer gpa.free(body); + + var parsed = try std.json.parseFromSlice(std.json.Value, gpa, body, .{}); + defer parsed.deinit(); + const object = switch (parsed.value) { + .object => |object| object, + else => return error.TestUnexpectedResult, + }; + try std.testing.expectEqualStrings( + "3f2b7c1e-0000-4000-8000-abcdef012345", + object.get("redeem_request_id").?.string, + ); + try std.testing.expectEqualStrings("credit\"1 \\ \u{7}\n", object.get("credit_id").?.string); +} + +test "idempotency key is a UUID v4" { + const key = try reset_credits_api.generateIdempotencyKeyAlloc(std.testing.allocator); + defer std.testing.allocator.free(key); + try std.testing.expectEqual(@as(usize, 36), key.len); + for ([_]usize{ 8, 13, 18, 23 }) |index| { + try std.testing.expectEqual(@as(u8, '-'), key[index]); + } + try std.testing.expectEqual(@as(u8, '4'), key[14]); + try std.testing.expect(key[19] == '8' or key[19] == '9' or key[19] == 'a' or key[19] == 'b'); + for (key, 0..) |ch, index| { + if (index == 8 or index == 13 or index == 18 or index == 23) continue; + try std.testing.expect((ch >= '0' and ch <= '9') or (ch >= 'a' and ch <= 'f')); + } +} diff --git a/tests/cli_integration_test.zig b/tests/cli_integration_test.zig index 75e2b6f8..d7cb4338 100644 --- a/tests/cli_integration_test.zig +++ b/tests/cli_integration_test.zig @@ -5181,3 +5181,130 @@ test "Scenario: Given unsupported native host with missing explicit codex CLI pa try std.testing.expect(std.mem.indexOf(u8, result.stderr, missing_cli_path) == null); try std.testing.expect(std.mem.indexOf(u8, result.stderr, "Environment Configuration") == null); } + +fn seedResetCreditsFixture(allocator: std.mem.Allocator, home_root: []const u8, email: []const u8) !void { + try seedRegistryWithAccounts(allocator, home_root, email, &.{ + .{ .email = email, .alias = "reset" }, + }); + const home = try codexHomeAlloc(allocator, home_root); + defer allocator.free(home); + + var reg = try registry.loadRegistry(allocator, home); + defer reg.deinit(allocator); + const key = try fixtures.accountKeyForEmailAlloc(allocator, email); + defer allocator.free(key); + const idx = registry.findAccountIndexByAccountKey(®, key) orelse return error.TestUnexpectedResult; + + const cards = try allocator.alloc(registry.ResetCreditCard, 2); + cards[0] = .{ + .id = try allocator.dupe(u8, "credit-later"), + .status = try allocator.dupe(u8, "available"), + .expires_at = try allocator.dupe(u8, "2027-01-01T00:00:00Z"), + .title = try allocator.dupe(u8, "Later card"), + }; + cards[1] = .{ + .id = try allocator.dupe(u8, "credit-soon"), + .status = try allocator.dupe(u8, "available"), + .expires_at = try allocator.dupe(u8, "2026-11-01T00:00:00Z"), + .title = try allocator.dupe(u8, "Full reset (Weekly + 5 hr)"), + }; + reg.accounts.items[idx].last_usage = .{ + .primary = null, + .secondary = null, + .credits = null, + .reset_credits = 2, + .reset_credit = cards, + .plan_type = .plus, + }; + try registry.saveRegistry(allocator, home, ®); +} + +fn writeResetConsumeFakeCurl(allocator: std.mem.Allocator, dir: fs.Dir) !void { + try dir.makePath("fake-curl-bin"); + const script = + "#!/bin/sh\n" ++ + "config=$(cat)\n" ++ + "case \"$config\" in\n" ++ + " *credit-soon*) printf '%s\\n200' '{\"code\":\"reset\",\"windows_reset\":1}' ;;\n" ++ + " *) exit 1 ;;\n" ++ + "esac\n"; + _ = allocator; + const sub_path = fakeCurlCommandPath(); + try dir.writeFile(.{ .sub_path = sub_path, .data = script }); + + var file = try dir.openFile(sub_path, .{ .mode = .read_write }); + defer file.close(); + try file.chmod(0o755); +} + +fn writeResetCreditsAuthFile(allocator: std.mem.Allocator, home_root: []const u8, email: []const u8) !void { + const home = try codexHomeAlloc(allocator, home_root); + defer allocator.free(home); + const key = try fixtures.accountKeyForEmailAlloc(allocator, email); + defer allocator.free(key); + const path = try registry.accountAuthPath(allocator, home, key); + defer allocator.free(path); + const data = try fixtures.authJsonWithEmailPlan(allocator, email, "plus"); + defer allocator.free(data); + try fs.cwd().writeFile(.{ .sub_path = path, .data = data }); +} + +test "Scenario: Given reset credits when running reset then it consumes the soonest-expiring card and lists the rest" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + + const email = "reset-user@example.com"; + try seedResetCreditsFixture(gpa, home_root, email); + try writeResetCreditsAuthFile(gpa, home_root, email); + try writeResetConsumeFakeCurl(gpa, tmp.dir); + + const fake_curl_dir = try tmp.dir.realpathAlloc(gpa, "fake-curl-bin"); + defer gpa.free(fake_curl_dir); + const path_override = try prependPathEntryAlloc(gpa, fake_curl_dir); + defer gpa.free(path_override); + + const result = try runCliWithIsolatedHomeAndPath(gpa, project_root, home_root, path_override, &[_][]const u8{ "reset", email }); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + logRunResultIfFailed("reset", result); + try expectSuccess(result); + try std.testing.expect(std.mem.indexOf(u8, result.stdout, "used \"Full reset (Weekly + 5 hr)\"") != null); + try std.testing.expect(std.mem.indexOf(u8, result.stdout, "\"Full reset (Weekly + 5 hr)\"") != null); + try std.testing.expect(std.mem.indexOf(u8, result.stdout, "remaining: 1 \u{00b7} next: Later card") != null); + try std.testing.expect(std.mem.indexOf(u8, result.stdout, "Later card") != null); + try std.testing.expectEqualStrings("", result.stderr); +} + +test "Scenario: Given no available reset credits when running reset then it reports and fails" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + + const email = "empty-reset@example.com"; + try seedRegistryWithAccounts(gpa, home_root, email, &.{ + .{ .email = email, .alias = "empty" }, + }); + + const result = try runCliWithIsolatedHome(gpa, project_root, home_root, &[_][]const u8{ "reset", email }); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectFailure(result); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, "no available reset credits") != null); +} diff --git a/tests/cli_picker_test.zig b/tests/cli_picker_test.zig index d751f0d2..259998c1 100644 --- a/tests/cli_picker_test.zig +++ b/tests/cli_picker_test.zig @@ -5,6 +5,7 @@ const app_runtime = codex_auth.core.runtime; const cli = codex_auth.cli; const live_tui = cli.live_tui; const registry = codex_auth.registry; +const text_width = codex_auth.tui.text_width; const isQuitInput = cli.picker.isQuitInput; const isQuitKey = cli.picker.isQuitKey; const activeSelectableIndex = cli.picker.activeSelectableIndex; @@ -1512,7 +1513,7 @@ test "Scenario: Given Windows console labels when rendering unicode-prone output removeTuiFooterText(true), ); try std.testing.expectEqualStrings( - "Keys: Up/Down scroll, PgUp/PgDn page, Home/End jump, Esc or q quit\n", + "Keys: Up/Down scroll, j/k row, c credits, PgUp/PgDn/Home/End page, Esc/q quit\n", listTuiFooterText(true), ); } @@ -1527,7 +1528,7 @@ test "Scenario: Given non-Windows console labels when rendering unicode-prone ou removeTuiFooterText(false), ); try std.testing.expectEqualStrings( - "Keys: ↑/↓ scroll, PgUp/PgDn page, Home/End jump, Esc or q quit\n", + "Keys: ↑/↓ scroll, j/k row, c credits, PgUp/PgDn/Home/End page, Esc/q quit\n", listTuiFooterText(false), ); } @@ -1704,6 +1705,342 @@ test "live list derives the reset credits count from available cards when the su try std.testing.expect(std.mem.indexOf(u8, body.written(), "1 (exp 10-01)") != null); } +test "reset credits panel renders the remaining rail and compact rows" { + const gpa = std.testing.allocator; + const now: i64 = 1_800_000_000; + const cards = [_]cli.reset_credits_panel.AvailableCard{ + .{ .id = "soon", .title = "Full reset (Weekly + 5 hr)", .expires_at = "2026-11-01T00:00:00Z", .epoch = now + 9 * 86400 }, + .{ .id = "undated", .title = null, .expires_at = null, .epoch = null }, + }; + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + _ = try cli.reset_credits_panel.render(gpa, &out.writer, &cards, 100, now, 0, true, "✓ used", 6); + const text = out.written(); + try std.testing.expect(std.mem.indexOf(u8, text, " ┌ Available 2 · ⏎ select · esc close") != null); + try std.testing.expect(std.mem.indexOf(u8, text, " │ ▸ Full reset (Weekly + 5 hr) · ") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "· in 9d · ⏎ again to use") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "│ (untitled) · -") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "│ ✓ used") != null); + try std.testing.expect(std.mem.endsWith(u8, text, "└\n")); +} + +test "reset credits panel hint tracks the armed credit identity" { + const gpa = std.testing.allocator; + const Step = cli.reset_credits_panel.ConfirmStep; + const Identity = cli.reset_credits_panel.Identity; + const card_zero = Identity{ .account_key = "acct-a", .credit_id = "card-0" }; + const card_one = Identity{ .account_key = "acct-a", .credit_id = "card-1" }; + var sel: cli.reset_credits_panel.Selection = .{ .expanded = true, .card_count = 2, .valid = true }; + defer sel.deinit(gpa); + + try std.testing.expectEqual(Step.armed, sel.confirm(gpa, card_zero)); + try std.testing.expect(sel.isPendingFor(card_zero)); + // The hint must never appear on a card that a second Enter would not redeem. + try std.testing.expect(!sel.isPendingFor(card_one)); + try std.testing.expect(!sel.isPendingFor(null)); + try std.testing.expect(!sel.isPendingFor(.{ .account_key = "acct-b", .credit_id = "card-0" })); +} + +test "reset credits panel approval is dropped by a cursor move queued in the same key batch" { + const gpa = std.testing.allocator; + const Step = cli.reset_credits_panel.ConfirmStep; + const card = cli.reset_credits_panel.Identity{ .account_key = "acct-a", .credit_id = "card-0" }; + var sel: cli.reset_credits_panel.Selection = .{ .expanded = true, .card_count = 2, .valid = true }; + defer sel.deinit(gpa); + + try std.testing.expectEqual(Step.armed, sel.confirm(gpa, card)); + try std.testing.expectEqual(Step.consume, sel.confirm(gpa, card)); + // A `j` that arrives in the same input batch as the two Enters would move + // the rail before the queued redeem runs, cancelling it silently; the live + // loop stops consuming the batch once a redeem is queued. + sel.moveRow(gpa, .down, 6); + try std.testing.expect(!sel.approve(gpa, card)); +} + +test "live list follows the credits rail row into the viewport" { + // The rail row is above the viewport: scroll up to it. + try std.testing.expectEqual(@as(usize, 2), live_tui.followViewportStart(40, 10, 20, 2)); + // Below the viewport: it becomes the last visible line. + try std.testing.expectEqual(@as(usize, 30), live_tui.followViewportStart(40, 10, 0, 39)); + // Already visible: unchanged. + try std.testing.expectEqual(@as(usize, 20), live_tui.followViewportStart(40, 10, 20, 25)); + // Without a rail the plain clamp still applies. + try std.testing.expectEqual(@as(usize, 20), live_tui.followViewportStart(40, 10, 20, null)); + try std.testing.expectEqual(@as(usize, 30), live_tui.followViewportStart(40, 10, 99, null)); + // Everything fits on screen, and a zero-height viewport, stay at the top. + try std.testing.expectEqual(@as(usize, 0), live_tui.followViewportStart(5, 10, 3, 4)); + try std.testing.expectEqual(@as(usize, 0), live_tui.followViewportStart(40, 0, 20, 5)); +} + +test "reset credits panel orders cards by parsed expiry and keeps undated cards last" { + const gpa = std.testing.allocator; + var reg = makeTestRegistry(); + defer reg.deinit(gpa); + try appendTestAccount(gpa, ®, "user::account", "test@example.com", "", .pro); + const details = try codex_auth.api.usage.parseResetCreditResponse(gpa, + \\{"available_count":3,"total_earned_count":4,"credits":[ + \\{"id":"nov","status":"available","expires_at":"2026-11-01T00:00:00Z","title":"Full reset (Weekly + 5 hr)"}, + \\{"id":"undated","status":"available","expires_at":null,"title":"Late grant"}, + \\{"id":"used","status":"consumed","expires_at":"2026-09-01T00:00:00Z","title":"Used card"}, + \\{"id":"oct","status":"available","expires_at":"2026-10-01T00:00:00Z","title":"Half reset"}]} + ); + reg.accounts.items[0].last_usage = .{ + .primary = null, + .secondary = null, + .credits = null, + .plan_type = .pro, + .reset_credits = 3, + .reset_credit = details, + }; + + const cards = try cli.reset_credits_panel.collectAvailable(gpa, ®.accounts.items[0], &.{}); + defer gpa.free(cards); + + try std.testing.expectEqual(@as(usize, 3), cards.len); + try std.testing.expectEqualStrings("oct", cards[0].id); + try std.testing.expectEqual(@as(i64, 1_790_812_800), cards[0].epoch.?); + try std.testing.expectEqualStrings("nov", cards[1].id); + try std.testing.expectEqual(@as(i64, 1_793_491_200), cards[1].epoch.?); + try std.testing.expectEqualStrings("undated", cards[2].id); + try std.testing.expect(cards[2].epoch == null); +} + +test "reset credits panel bounds multibyte lines by display width" { + const gpa = std.testing.allocator; + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + _ = try cli.reset_credits_panel.render(gpa, &out.writer, &.{}, 7, 1_800_000_000, 0, false, "✓ used", 0); + var lines = std.mem.splitScalar(u8, out.written(), '\n'); + while (lines.next()) |line| { + if (line.len == 0) continue; + try std.testing.expect(std.unicode.utf8ValidateSlice(line)); + try std.testing.expect(text_width.displayWidth(line) <= 7); + } +} + +test "reset credits panel requires a second confirm on the same credit after moving" { + const gpa = std.testing.allocator; + const Step = cli.reset_credits_panel.ConfirmStep; + const card_zero = cli.reset_credits_panel.Identity{ .account_key = "acct-a", .credit_id = "card-0" }; + const card_one = cli.reset_credits_panel.Identity{ .account_key = "acct-a", .credit_id = "card-1" }; + var sel: cli.reset_credits_panel.Selection = .{ .expanded = true, .card_count = 2, .valid = true }; + defer sel.deinit(gpa); + + try std.testing.expectEqual(Step.armed, sel.confirm(gpa, card_zero)); + try std.testing.expect(sel.isPendingFor(card_zero)); + sel.moveCard(gpa, .down); + try std.testing.expect(!sel.isPendingFor(card_zero)); + try std.testing.expectEqual(@as(usize, 1), sel.card_cursor); + try std.testing.expectEqual(Step.armed, sel.confirm(gpa, card_one)); + try std.testing.expectEqual(Step.consume, sel.confirm(gpa, card_one)); + try std.testing.expect(sel.approve(gpa, card_one)); + try std.testing.expect(!sel.approve(gpa, card_one)); +} + +test "reset credits panel drops a pending confirm when the account row moves" { + const gpa = std.testing.allocator; + const Step = cli.reset_credits_panel.ConfirmStep; + const card = cli.reset_credits_panel.Identity{ .account_key = "acct-a", .credit_id = "card-0" }; + var sel: cli.reset_credits_panel.Selection = .{ .expanded = true, .card_count = 2, .valid = true }; + defer sel.deinit(gpa); + + try std.testing.expectEqual(Step.armed, sel.confirm(gpa, card)); + sel.moveRow(gpa, .down, 4); + try std.testing.expectEqual(@as(usize, 1), sel.row_cursor); + try std.testing.expect(!sel.isPendingFor(card)); + try std.testing.expect(!sel.valid); + try std.testing.expectEqual(Step.ignored, sel.confirm(gpa, card)); + sel.moveRow(gpa, .down, 4); + sel.clampRow(gpa, 1); + try std.testing.expectEqual(@as(usize, 0), sel.row_cursor); + try std.testing.expect(!sel.valid); + sel.updateCards(gpa, 2); + try std.testing.expect(!sel.isPendingFor(card)); + try std.testing.expectEqual(@as(usize, 0), sel.card_cursor); +} + +test "reset credits panel cannot confirm a row without a consumable credit" { + const gpa = std.testing.allocator; + const Step = cli.reset_credits_panel.ConfirmStep; + const card = cli.reset_credits_panel.Identity{ .account_key = "acct-a", .credit_id = "card-0" }; + var sel: cli.reset_credits_panel.Selection = .{ .expanded = true, .card_count = 2, .valid = true }; + defer sel.deinit(gpa); + + try std.testing.expectEqual(Step.armed, sel.confirm(gpa, card)); + sel.clearSelection(gpa); + try std.testing.expect(!sel.valid); + try std.testing.expect(!sel.isPendingFor(card)); + try std.testing.expectEqual(Step.ignored, sel.confirm(gpa, card)); + try std.testing.expectEqual(Step.ignored, sel.confirm(gpa, card)); + try std.testing.expect(!sel.approve(gpa, card)); +} + +test "reset credits panel binds a pending confirm to one account and credit" { + const gpa = std.testing.allocator; + const Step = cli.reset_credits_panel.ConfirmStep; + const first = cli.reset_credits_panel.Identity{ .account_key = "acct-a", .credit_id = "card-0" }; + const other_credit = cli.reset_credits_panel.Identity{ .account_key = "acct-a", .credit_id = "card-9" }; + const other_account = cli.reset_credits_panel.Identity{ .account_key = "acct-b", .credit_id = "card-0" }; + var sel: cli.reset_credits_panel.Selection = .{ .expanded = true, .card_count = 2, .valid = true }; + defer sel.deinit(gpa); + + try std.testing.expectEqual(Step.armed, sel.confirm(gpa, first)); + // A refresh that keeps the list length but swaps the highlighted card must + // not let the queued second Enter redeem the new card. + try std.testing.expectEqual(Step.armed, sel.confirm(gpa, other_credit)); + try std.testing.expect(!sel.approve(gpa, first)); + try std.testing.expectEqual(Step.armed, sel.confirm(gpa, other_credit)); + try std.testing.expect(!sel.approve(gpa, other_account)); + try std.testing.expectEqual(Step.armed, sel.confirm(gpa, other_credit)); + try std.testing.expectEqual(Step.consume, sel.confirm(gpa, other_credit)); + try std.testing.expect(sel.approve(gpa, other_credit)); + try std.testing.expect(!sel.approve(gpa, other_credit)); +} + +test "reset credits panel keeps a confirm armed only while the credit stays highlighted" { + const gpa = std.testing.allocator; + const Step = cli.reset_credits_panel.ConfirmStep; + const first = cli.reset_credits_panel.Identity{ .account_key = "acct-a", .credit_id = "card-0" }; + var sel: cli.reset_credits_panel.Selection = .{ .expanded = true, .card_count = 2, .valid = true }; + defer sel.deinit(gpa); + + try std.testing.expectEqual(Step.armed, sel.confirm(gpa, first)); + sel.updateCards(gpa, 2); + try std.testing.expect(sel.isPendingFor(first)); + try std.testing.expect(sel.valid); + sel.moveCard(gpa, .down); + const second = cli.reset_credits_panel.Identity{ .account_key = "acct-a", .credit_id = "card-1" }; + try std.testing.expectEqual(Step.armed, sel.confirm(gpa, second)); + sel.updateCards(gpa, 1); + try std.testing.expect(!sel.isPendingFor(second)); + try std.testing.expectEqual(@as(usize, 0), sel.card_cursor); +} + +test "reset credits panel close drops the pending confirm and the status line" { + const gpa = std.testing.allocator; + const Step = cli.reset_credits_panel.ConfirmStep; + const card = cli.reset_credits_panel.Identity{ .account_key = "acct-a", .credit_id = "card-0" }; + var sel: cli.reset_credits_panel.Selection = .{ .expanded = true, .card_count = 2, .valid = true }; + defer sel.deinit(gpa); + + try sel.setStatus(gpa, "✓ reset credit used"); + try std.testing.expectEqual(Step.armed, sel.confirm(gpa, card)); + sel.close(gpa); + try std.testing.expect(!sel.expanded); + try std.testing.expect(!sel.isPendingFor(card)); + try std.testing.expectEqualStrings("", sel.status orelse ""); + try std.testing.expectEqual(Step.ignored, sel.confirm(gpa, card)); + sel.toggleExpanded(gpa); + try std.testing.expect(sel.expanded); + try std.testing.expect(!sel.isPendingFor(card)); + try std.testing.expect(sel.status == null); +} + +test "reset credits panel clears the status line when the account row moves" { + const gpa = std.testing.allocator; + var sel: cli.reset_credits_panel.Selection = .{ .expanded = true, .card_count = 2, .valid = true }; + defer sel.deinit(gpa); + + try sel.setStatus(gpa, "✓ reset credit used"); + sel.moveRow(gpa, .down, 5); + try std.testing.expect(sel.status == null); + try sel.setStatus(gpa, "✗ use failed (HTTP 500)"); + sel.clearSelection(gpa); + try std.testing.expect(sel.status == null); +} + +test "reset credits panel splice keeps one line per rendered row" { + const gpa = std.testing.allocator; + const body = "header\nrow-a\nrow-b\n"; + + const middle = try cli.reset_credits_panel.spliceAfterDataLine(gpa, body, 0, "RAIL\n"); + defer gpa.free(middle); + try std.testing.expectEqualStrings("header\nrow-a\nRAIL\nrow-b\n", middle); + + const last = try cli.reset_credits_panel.spliceAfterDataLine(gpa, body, 1, "RAIL\n"); + defer gpa.free(last); + try std.testing.expectEqualStrings("header\nrow-a\nrow-b\nRAIL\n", last); + try std.testing.expectEqual(@as(usize, 4), std.mem.count(u8, last, "\n")); +} + +test "live list maps the credits rail cursor to the rendered account row" { + const gpa = std.testing.allocator; + var reg = makeTestRegistry(); + defer reg.deinit(gpa); + // Two accounts sharing one email render as a group (header row plus two + // account rows) alongside a singleton account, so the row indices of + // rows.items and the rendered data-row indices cannot be used interchangeably. + try appendTestAccount(gpa, ®, "user-1::acct-a", "shared@example.com", "", .pro); + try appendTestAccount(gpa, ®, "user-1::acct-b", "shared@example.com", "", .plus); + try appendTestAccount(gpa, ®, "user-2::acct-c", "other@example.com", "", .pro); + + var rows = try buildSwitchRows(gpa, ®); + defer rows.deinit(gpa); + + var body: std.Io.Writer.Allocating = .init(gpa); + defer body.deinit(); + var writer = StyledWriter.init(&body.writer, false); + try cli.render.renderListBody(gpa, &writer, ®, rows.items, 2, rows.widths, null); + + const account_count = reg.accounts.items.len; + var header_rows: usize = 0; + for (rows.items) |row| { + if (row.is_header) header_rows += 1; + } + try std.testing.expectEqual(@as(usize, 1), header_rows); + try std.testing.expectEqual(@as(usize, 3), cli.render.liveDataRowCount(rows.items, account_count)); + try std.testing.expectEqual( + std.mem.count(u8, body.written(), "\n") - 1, + cli.render.liveDataRowCount(rows.items, account_count), + ); + + var iter = std.mem.splitScalar(u8, body.written(), '\n'); + _ = iter.next(); // column header line + var shifted: usize = 0; + var naive_hits_header: usize = 0; + for (0..3) |data_index| { + const line = iter.next() orelse return error.TestUnexpectedResult; + const row_index = cli.render.liveRowIndexForDataIndex(rows.items, account_count, data_index) orelse + return error.TestUnexpectedResult; + try std.testing.expect(!rows.items[row_index].is_header); + try std.testing.expect(std.mem.indexOf(u8, line, rows.items[row_index].account) != null); + if (row_index != data_index) shifted += 1; + if (rows.items[data_index].is_header) naive_hits_header += 1; + } + // The header row is not drawn, so the naive `rows.items[data_index]` + // indexing both points at a group header and desyncs the rail from the row + // it is spliced under. + try std.testing.expect(naive_hits_header > 0); + try std.testing.expect(shifted > 0); + try std.testing.expect(cli.render.liveRowIndexForDataIndex(rows.items, account_count, 3) == null); +} + +test "live list renders a dash when available reset credits have no expiry" { + const gpa = std.testing.allocator; + var reg = makeTestRegistry(); + defer reg.deinit(gpa); + try appendTestAccount(gpa, ®, "user::account", "test@example.com", "", .pro); + const details = try codex_auth.api.usage.parseResetCreditResponse(gpa, + \\{"available_count":1,"total_earned_count":1,"credits":[ + \\{"id":"undated-card","reset_type":"codex_rate_limits","status":"available","granted_at":"2026-09-01T00:00:00Z","expires_at":null,"title":"Undated card"}]} + ); + reg.accounts.items[0].last_usage = .{ + .primary = .{ .used_percent = 30, .window_minutes = 300, .resets_at = 1790000100 }, + .secondary = null, + .credits = null, + .plan_type = .pro, + .reset_credits = 1, + .reset_credit = details, + }; + var rows = try buildSwitchRows(gpa, ®); + defer rows.deinit(gpa); + var body: std.Io.Writer.Allocating = .init(gpa); + defer body.deinit(); + var styled_body = StyledWriter.init(&body.writer, false); + try cli.render.renderListBody(gpa, &styled_body, ®, rows.items, 2, rows.widths, null); + try std.testing.expect(std.mem.indexOf(u8, body.written(), "1 (-)") != null); +} + test "live list shows plan expiry only when it is still in the future" { const gpa = std.testing.allocator; const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); @@ -1776,7 +2113,7 @@ test "live list distinguishes unavailable reset details from an empty server lis try std.testing.expect(std.mem.indexOf(u8, body.written(), "CREDIT 1 EXP") == null); } -test "live list defers PLAN EXPIRE and keeps the configured column order" { +test "live list details keep the responsive column order and complete percentages" { const gpa = std.testing.allocator; const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); var reg = makeTestRegistry(); @@ -1793,6 +2130,24 @@ test "live list defers PLAN EXPIRE and keeps the configured column order" { var rows = try buildSwitchRows(gpa, ®); defer rows.deinit(gpa); + var width: usize = 20; + while (width <= 140) : (width += 1) { + var body: std.Io.Writer.Allocating = .init(gpa); + defer body.deinit(); + var writer = StyledWriter.init(&body.writer, false); + try cli.render.renderListBody(gpa, &writer, ®, rows.items, 2, rows.widths, width); + + var lines = std.mem.splitScalar(u8, body.written(), '\n'); + const header = lines.next() orelse ""; + while (lines.next()) |line| { + if (line.len == 0) continue; + try std.testing.expect(line.len <= width); + } + if (std.mem.indexOf(u8, header, "5H") != null) { + try std.testing.expect(std.mem.indexOf(u8, body.written(), "100%") != null); + } + } + var narrow_body: std.Io.Writer.Allocating = .init(gpa); defer narrow_body.deinit(); var narrow_writer = StyledWriter.init(&narrow_body.writer, false); @@ -1808,7 +2163,7 @@ test "live list defers PLAN EXPIRE and keeps the configured column order" { const body = wide_body.written(); try std.testing.expect(std.mem.indexOf(u8, body, "PLAN EXPIRE") != null); var last_index: usize = 0; - for ([_][]const u8{ "#*", "ACCOUNT", "PLAN", "PLAN EXPIRE", "RESET CREDITS", "5H", "WEEKLY", "AUTH EXPIRE", "LAST" }) |token| { + for ([_][]const u8{ "#*", "ACCOUNT", "PLAN", "PLAN EXPIRE", "RESET CREDITS", "5H", "WEEKLY", "AUTH EXPIRE", "LAST ACTIVITY" }) |token| { const index = std.mem.indexOf(u8, body, token) orelse return error.TestUnexpectedResult; try std.testing.expect(index >= last_index); last_index = index; @@ -1841,3 +2196,41 @@ test "live list renders zero reset credits without an expiry marker" { try std.testing.expect(std.mem.indexOf(u8, body.written(), "0 (-)") == null); try std.testing.expect(std.mem.indexOf(u8, body.written(), "0") != null); } + +test "mouse wheel step scales with visible rows" { + try std.testing.expectEqual(@as(usize, 6), live_tui.mouseWheelRows(1)); + try std.testing.expectEqual(@as(usize, 6), live_tui.mouseWheelRows(18)); + try std.testing.expectEqual(@as(usize, 10), live_tui.mouseWheelRows(30)); + try std.testing.expectEqual(@as(usize, 12), live_tui.mouseWheelRows(80)); +} + +test "list viewport keys keep paging and accept alternate-scroll wheel arrows" { + var viewport_start: usize = 9; + const row_count: usize = 105; + const max_rows: usize = 20; + const wheel_rows: usize = live_tui.mouseWheelRows(max_rows); + + try std.testing.expect(live_tui.applyListViewportKey(row_count, max_rows, &viewport_start, wheel_rows, .move_up)); + try std.testing.expectEqual(@as(usize, 3), viewport_start); + try std.testing.expect(live_tui.applyListViewportKey(row_count, max_rows, &viewport_start, wheel_rows, .move_down)); + try std.testing.expectEqual(@as(usize, 9), viewport_start); + try std.testing.expect(!live_tui.applyListViewportKey(row_count, max_rows, &viewport_start, wheel_rows, .{ .byte = 'j' })); + try std.testing.expectEqual(@as(usize, 9), viewport_start); + try std.testing.expect(!live_tui.applyListViewportKey(row_count, max_rows, &viewport_start, wheel_rows, .{ .byte = 'k' })); + try std.testing.expectEqual(@as(usize, 9), viewport_start); + + try std.testing.expect(live_tui.applyListViewportKey(row_count, max_rows, &viewport_start, wheel_rows, .page_down)); + try std.testing.expectEqual(@as(usize, 29), viewport_start); + try std.testing.expect(live_tui.applyListViewportKey(row_count, max_rows, &viewport_start, wheel_rows, .page_up)); + try std.testing.expectEqual(@as(usize, 9), viewport_start); + try std.testing.expect(live_tui.applyListViewportKey(row_count, max_rows, &viewport_start, wheel_rows, .home)); + try std.testing.expectEqual(@as(usize, 0), viewport_start); + try std.testing.expect(live_tui.applyListViewportKey(row_count, max_rows, &viewport_start, wheel_rows, .end)); + try std.testing.expectEqual(@as(usize, 85), viewport_start); +} + +test "display width counts UTF-8 codepoints instead of bytes" { + try std.testing.expectEqual(@as(usize, 7), text_width.displayWidth("Рабочая")); + try std.testing.expectEqual(@as(usize, 6), text_width.prefixByteLength("Рабочая", 3)); + try std.testing.expectEqualStrings("чая", "Рабочая"[text_width.suffixStart("Рабочая", 3)..]); +} diff --git a/tests/table_layout_test.zig b/tests/table_layout_test.zig index eeb6226e..393f577d 100644 --- a/tests/table_layout_test.zig +++ b/tests/table_layout_test.zig @@ -303,3 +303,15 @@ test "Scenario: Given low-priority metadata when narrowing then LAST hides befor try std.testing.expect(std.mem.indexOf(u8, output, "LAST") == null); try std.testing.expect(std.mem.indexOf(u8, output, "CR") != null); } + +test "plain and live views keep shared columns in the same relative order" { + const table_layout = cli.table_layout; + var previous: ?usize = null; + for (table_layout.list_detail_columns) |id| { + if (id == .number) continue; + const position = std.mem.indexOfScalar(table_layout.ColumnId, &table_layout.plain_columns, id) orelse + return error.TestUnexpectedResult; + if (previous) |prev| try std.testing.expect(position > prev); + previous = position; + } +}