From ffaabba3af8bda5689c3739a51de3dd34eb71383 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Fri, 16 Oct 2026 10:21:35 +0800 Subject: [PATCH 01/19] wip: reset credits detail view in list --live Adds a read-only RESET CREDITS detail block to the list --live screen: - 'c' toggles the block under the cursor row; j/k move the cursor row - lists available reset credits sorted by expires_at ascending, one line per card (title, local expiry, relative countdown), bounded to terminal width - read-only: redemption stays in codex sessions (contextual picker on rate-limit hit); no changes to the data layer from #159 Stacked on #159; draft for design review of the panel interaction. [skip ci] --- src/cli/live_view.zig | 51 +++++++- src/cli/reset_credits_panel.zig | 202 ++++++++++++++++++++++++++++++++ 2 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 src/cli/reset_credits_panel.zig diff --git a/src/cli/live_view.zig b/src/cli/live_view.zig index 6069677b..065f1a64 100644 --- a/src/cli/live_view.zig +++ b/src/cli/live_view.zig @@ -9,6 +9,7 @@ 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"); pub const SwitchSelectionDisplay = selection.SwitchSelectionDisplay; pub const OwnedSwitchSelectionDisplay = selection.OwnedSwitchSelectionDisplay; @@ -259,6 +260,10 @@ 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 credits_cursor: usize = 0; + var credits_expanded: bool = false; while (true) { if (try controller.maybe_take_updated_display(controller.context)) |updated| { @@ -279,7 +284,34 @@ 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); + const data_rows = std.mem.count(u8, body_text, "\n") -| 1; + if (credits_cursor >= data_rows) credits_cursor = if (data_rows == 0) 0 else data_rows - 1; + if (credits_expanded and data_rows > 0) { + const cursor_row = rows.items[credits_cursor]; + if (cursor_row.account_index) |account_idx| { + if (account_idx < current_display.reg.accounts.items.len) { + credits_panel.clearRetainingCapacity(); + _ = try reset_credits_panel.render( + allocator, + &credits_panel.writer, + ¤t_display.reg.accounts.items[account_idx], + tui.terminalCols(), + now_second, + ); + spliced_panel = try reset_credits_panel.spliceAfterDataLine( + allocator, + body_text, + credits_cursor, + credits_panel.written(), + ); + body_text = spliced_panel.?; + } + } + } + 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( @@ -295,7 +327,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); @@ -326,6 +358,21 @@ pub fn viewAccountsWithLiveUpdates( .redraw => needs_render = true, .byte => |ch| { if (isQuitKey(ch)) return; + if (ch == 'c') { + credits_expanded = !credits_expanded; + needs_render = true; + continue; + } + if (ch == 'j') { + if (credits_cursor + 1 < rendered_row_count) credits_cursor += 1; + needs_render = true; + continue; + } + if (ch == 'k') { + if (credits_cursor > 0) credits_cursor -= 1; + needs_render = true; + continue; + } }, else => {}, } diff --git a/src/cli/reset_credits_panel.zig b/src/cli/reset_credits_panel.zig new file mode 100644 index 00000000..03b8be4b --- /dev/null +++ b/src/cli/reset_credits_panel.zig @@ -0,0 +1,202 @@ +const std = @import("std"); +const registry = @import("../registry/root.zig"); +const rows = @import("rows.zig"); + +const available_status = "available"; + +const Card = struct { + title: ?[]const u8, + expires_at: ?[]const u8, + epoch: ?i64, +}; + +/// Renders the interactive RESET CREDITS detail block for one account and returns +/// the number of lines written. Lines are bounded to `max_cols` and terminated +/// with '\n'. Read-only: this view never redeems; redemption happens inside a +/// codex session when a rate limit is hit. +pub fn render( + allocator: std.mem.Allocator, + writer: *std.Io.Writer, + account: *const registry.AccountRecord, + max_cols: usize, + now: i64, +) !usize { + var cards: std.ArrayList(Card) = .empty; + defer cards.deinit(allocator); + var available: usize = 0; + var has_details = false; + + if (account.last_usage) |usage| { + if (usage.reset_credit_details) |details| { + has_details = true; + for (details.credits) |credit| { + if (!std.mem.eql(u8, credit.status, available_status)) continue; + available += 1; + try cards.append(allocator, .{ + .title = credit.title, + .expires_at = credit.expires_at, + .epoch = parseIsoEpoch(credit.expires_at orelse ""), + }); + } + } + } + if (!has_details) available = 0; + if (account.last_usage) |usage| { + if (has_details) { + if (usage.reset_credits) |count| available = @intCast(@max(0, count)); + } + } + + std.mem.sort(Card, cards.items, {}, cardLessThan); + + var lines: usize = 0; + var header_buf: [128]u8 = undefined; + const header = std.fmt.bufPrint(&header_buf, "RESET CREDITS · {d} available", .{available}) catch "RESET CREDITS"; + try writeRule(writer, "┌ ", header, max_cols, true); + try writer.writeByte('\n'); + lines += 1; + + if (!has_details) { + try writeBounded(writer, "│ no reset-credit data · run `codex-auth list --api` to refresh", max_cols); + try writer.writeByte('\n'); + lines += 1; + } else if (cards.items.len == 0) { + try writeBounded(writer, "│ no available reset credits", max_cols); + try writer.writeByte('\n'); + lines += 1; + } else { + for (cards.items) |card| { + var line: std.Io.Writer.Allocating = .init(allocator); + defer line.deinit(); + try line.writer.writeAll(card.title orelse "(untitled)"); + if (card.expires_at) |expiry| { + if (parseIsoEpoch(expiry)) |epoch| { + const local = try rows.formatTimestampAlloc(allocator, epoch); + defer allocator.free(local); + const rel = try relativeIn(allocator, epoch, now); + defer allocator.free(rel); + try line.writer.print(" · expires {s} ({s})", .{ local, rel }); + } else { + try line.writer.print(" · expires {s}", .{expiry}); + } + } else { + try line.writer.writeAll(" · no expiry"); + } + try writer.writeAll("│ "); + try writeBounded(writer, line.written(), max_cols); + try writer.writeByte('\n'); + lines += 1; + } + } + + try writeRule(writer, "└ ", "⏎ collapse", max_cols, false); + lines += 1; + return lines; +} + +/// 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. +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) { + 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'); + } + } + return allocator.dupe(u8, out.written()); +} + +fn cardLessThan(_: void, a: Card, b: Card) 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; +} + +/// Minimal ISO-8601 parser for the backend shape `YYYY-MM-DDTHH:MM:SS` with an +/// optional trailing zone (`Z` or `±HH:MM`). Returns epoch seconds, or null. +fn parseIsoEpoch(value: []const u8) ?i64 { + if (value.len < 19 or value[4] != '-' or value[7] != '-' or value[10] != 'T' or + value[13] != ':' or value[16] != ':') return null; + const year = std.fmt.parseInt(i64, value[0..4], 10) catch return null; + const month = std.fmt.parseInt(i64, value[5..7], 10) catch return null; + const day = std.fmt.parseInt(i64, value[8..10], 10) catch return null; + const hour = std.fmt.parseInt(i64, value[11..13], 10) catch return null; + const minute = std.fmt.parseInt(i64, value[14..16], 10) catch return null; + const second = std.fmt.parseInt(i64, value[17..19], 10) catch return null; + if (month < 1 or month > 12 or day < 1 or day > 31) return null; + + var epoch = daysFromCivil(year, month, day) * 86400 + hour * 3600 + minute * 60 + second; + if (value.len > 19) { + const zone_marker = value[19]; + if (zone_marker == '+' or zone_marker == '-') { + if (value.len >= 25 and value[22] == ':') { + const zone_hour = std.fmt.parseInt(i64, value[20..22], 10) catch 0; + const zone_minute = std.fmt.parseInt(i64, value[23..25], 10) catch 0; + const offset = zone_hour * 3600 + zone_minute * 60; + epoch += if (zone_marker == '+') -offset else offset; + } + } + } + return epoch; +} + +/// Howard Hinnant's days_from_civil algorithm. +fn daysFromCivil(year: i64, month: i64, day: i64) i64 { + var y = year; + if (month <= 2) y -= 1; + const era = @divFloor(y, 400); + const yoe = y - era * 400; + const mp = @mod(month + 9, 12); + const doy = @divFloor(153 * mp + 2, 5) + day - 1; + const doe = yoe * 365 + @divFloor(yoe, 4) - @divFloor(yoe, 100) + doy; + return era * 146097 + doe - 719468; +} + +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 writeRule(writer: *std.Io.Writer, open: []const u8, label: []const u8, max_cols: usize, top: bool) !void { + try writer.writeAll(open); + try writer.writeAll(label); + try writer.writeAll(" "); + var used: usize = open.len + label.len + 1; + const corner: usize = 1; + if (used + corner > max_cols) return; + while (used + corner < max_cols) { + try writer.writeAll("─"); + used += 1; + } + try writer.writeAll(if (top) "┐" else "┘"); +} + +fn writeBounded(writer: *std.Io.Writer, text: []const u8, max_cols: usize) !void { + if (text.len <= max_cols) { + try writer.writeAll(text); + return; + } + if (max_cols <= 3) { + try writer.writeAll("..."); + return; + } + try writer.writeAll(text[0 .. max_cols - 3]); + try writer.writeAll("..."); +} + +test "parseIsoEpoch handles backend strings" { + const epoch = parseIsoEpoch("2026-11-01T00:00:00Z"); + try std.testing.expect(epoch != null); + try std.testing.expectEqual(@as(i64, 1793491200), epoch.?); + try std.testing.expect(parseIsoEpoch("nonsense") == null); +} From 0c1503f4db8ef2f7a0de11b0f01094e195fbfa06 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Fri, 16 Oct 2026 19:35:47 +0800 Subject: [PATCH 02/19] wip: direct use (redeem) for the selected reset credit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ⏎ on the expanded panel arms a two-step confirm (mirrors the upstream 'Use this reset?' UX); second ⏎ consumes the selected credit - new api/reset_credits.zig: POST wham/rate-limit-reset-credits/consume with a client-generated UUID v4 redeem_request_id (upstream semantics); per-account auth via registry.accountAuthPath - http_curl.zig: POST JSON curl runner (mirrors the GET runner) - panel: available-card collection (status==available, minus session consumed ids, sorted by expiry), cursor/detail/confirm/status lines - registry is left untouched on use: session-scoped consumed ids filter the display; the next --api refresh reconciles [skip ci] --- src/api/http.zig | 1 + src/api/http_curl.zig | 60 +++++++++++++++++ src/api/reset_credits.zig | 100 ++++++++++++++++++++++++++++ src/cli/live_view.zig | 102 +++++++++++++++++++++++++++- src/cli/reset_credits_panel.zig | 114 ++++++++++++++++++++------------ 5 files changed, 332 insertions(+), 45 deletions(-) create mode 100644 src/api/reset_credits.zig 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..a64dc320 --- /dev/null +++ b/src/api/reset_credits.zig @@ -0,0 +1,100 @@ +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"; + +pub const ConsumeResult = struct { + ok: bool, + 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; + } +}; + +/// Mirrors upstream: the client generates the idempotency key (UUID v4) and +/// sends it as `redeem_request_id` together with the chosen `credit_id`. +fn randomIdempotencyKeyAlloc(allocator: std.mem.Allocator) ![]u8 { + var raw: [16]u8 = undefined; + const nanos = std.Io.Timestamp.now(app_runtime.io(), .real).toNanoseconds(); + var prng = std.Random.DefaultPrng.init(@truncate(@as(u96, @bitCast(nanos)))); + prng.random().bytes(&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..]); +} + +fn fail(allocator: std.mem.Allocator, name: []const u8) ConsumeResult { + return .{ + .ok = false, + .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. 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, + 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 idempotency_key = try randomIdempotencyKeyAlloc(allocator); + defer allocator.free(idempotency_key); + const body = try std.fmt.allocPrint( + allocator, + "{{\"redeem_request_id\":\"{s}\",\"credit_id\":\"{s}\"}}", + .{ idempotency_key, 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 ok = http_result.status_code != null and + http_result.status_code.? >= 200 and http_result.status_code.? < 300; + return .{ + .ok = ok, + .status_code = http_result.status_code, + .error_name = null, + .body = http_result.body, + }; +} diff --git a/src/cli/live_view.zig b/src/cli/live_view.zig index 065f1a64..45d541b8 100644 --- a/src/cli/live_view.zig +++ b/src/cli/live_view.zig @@ -10,6 +10,13 @@ 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"); + +fn setPanelStatus(allocator: std.mem.Allocator, slot: *?[]u8, text: []const u8) !void { + if (slot.*) |old| allocator.free(old); + slot.* = try allocator.dupe(u8, text); +} pub const SwitchSelectionDisplay = selection.SwitchSelectionDisplay; pub const OwnedSwitchSelectionDisplay = selection.OwnedSwitchSelectionDisplay; @@ -264,6 +271,21 @@ pub fn viewAccountsWithLiveUpdates( defer credits_panel.deinit(); var credits_cursor: usize = 0; var credits_expanded: bool = false; + var card_cursor: usize = 0; + var confirm_pending: bool = false; + var pending_consume: bool = false; + var panel_status: ?[]u8 = null; + var selected_credit_id: ?[]u8 = null; + var selected_account_key: ?[]u8 = null; + var available_count_cache: usize = 0; + var consumed_ids: std.ArrayList([]u8) = .empty; + defer { + for (consumed_ids.items) |id| allocator.free(id); + consumed_ids.deinit(allocator); + if (panel_status) |status| allocator.free(status); + if (selected_credit_id) |id| allocator.free(id); + if (selected_account_key) |key| allocator.free(key); + } while (true) { if (try controller.maybe_take_updated_display(controller.context)) |updated| { @@ -287,19 +309,32 @@ pub fn viewAccountsWithLiveUpdates( 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 data_rows = std.mem.count(u8, body_text, "\n") -| 1; if (credits_cursor >= data_rows) credits_cursor = if (data_rows == 0) 0 else data_rows - 1; if (credits_expanded and data_rows > 0) { const cursor_row = rows.items[credits_cursor]; if (cursor_row.account_index) |account_idx| { if (account_idx < current_display.reg.accounts.items.len) { + const account = ¤t_display.reg.accounts.items[account_idx]; + available_cards = try reset_credits_panel.collectAvailable( + allocator, + account, + consumed_ids.items, + ); + available_count_cache = available_cards.len; + if (card_cursor >= available_cards.len) card_cursor = if (available_cards.len == 0) 0 else available_cards.len - 1; credits_panel.clearRetainingCapacity(); _ = try reset_credits_panel.render( allocator, &credits_panel.writer, - ¤t_display.reg.accounts.items[account_idx], + available_cards, tui.terminalCols(), now_second, + card_cursor, + confirm_pending, + panel_status, ); spliced_panel = try reset_credits_panel.spliceAfterDataLine( allocator, @@ -308,6 +343,13 @@ pub fn viewAccountsWithLiveUpdates( credits_panel.written(), ); body_text = spliced_panel.?; + if (selected_credit_id) |id| allocator.free(id); + selected_credit_id = if (available_cards.len > 0) + try allocator.dupe(u8, available_cards[card_cursor].id) + else + null; + if (selected_account_key) |key| allocator.free(key); + selected_account_key = try allocator.dupe(u8, account.account_key); } } } @@ -349,6 +391,34 @@ pub fn viewAccountsWithLiveUpdates( const wheel_rows = live_tui.mouseWheelRows(max_rows); for (key_buf[0..key_count]) |key| { + if (credits_expanded) { + switch (key) { + .move_up, .keyboard_up => { + if (card_cursor > 0) card_cursor -= 1; + needs_render = true; + continue; + }, + .move_down, .keyboard_down => { + card_cursor += 1; + needs_render = true; + continue; + }, + .enter => { + if (available_count_cache > 0 and selected_credit_id != null) { + if (!confirm_pending) { + confirm_pending = true; + needs_render = true; + } else { + confirm_pending = false; + pending_consume = true; + needs_render = true; + } + } + continue; + }, + else => {}, + } + } if (live_tui.applyListViewportKey(rendered_row_count, max_rows, &viewport_start, wheel_rows, key)) { needs_render = true; continue; @@ -377,6 +447,36 @@ pub fn viewAccountsWithLiveUpdates( else => {}, } } + if (pending_consume) { + pending_consume = false; + if (selected_credit_id != null and selected_account_key != null) { + var result: reset_credits_api.ConsumeResult = blk: { + const codex_home = registry.resolveCodexHome(allocator) catch |err| { + break :blk reset_credits_api.ConsumeResult{ .ok = false, .error_name = try allocator.dupe(u8, @errorName(err)) }; + }; + defer allocator.free(codex_home); + break :blk reset_credits_api.consumeCreditForAccount( + allocator, + codex_home, + selected_account_key.?, + selected_credit_id.?, + ) catch |err| reset_credits_api.ConsumeResult{ .ok = false, .error_name = try allocator.dupe(u8, @errorName(err)) }; + }; + defer result.deinit(allocator); + if (result.ok) { + const id_copy = try allocator.dupe(u8, selected_credit_id.?); + try consumed_ids.append(allocator, id_copy); + try setPanelStatus(allocator, &panel_status, "✓ reset credit used · windows reset on next refresh"); + } else if (result.status_code) |code| { + const text = try std.fmt.allocPrint(allocator, "✗ use failed (HTTP {d})", .{code}); + defer allocator.free(text); + try setPanelStatus(allocator, &panel_status, text); + } else { + try setPanelStatus(allocator, &panel_status, result.error_name orelse "use failed"); + } + needs_render = true; + } + } } }, } diff --git a/src/cli/reset_credits_panel.zig b/src/cli/reset_credits_panel.zig index 03b8be4b..453a37e0 100644 --- a/src/cli/reset_credits_panel.zig +++ b/src/cli/reset_credits_panel.zig @@ -4,70 +4,78 @@ const rows = @import("rows.zig"); const available_status = "available"; -const Card = struct { +pub const AvailableCard = struct { + id: []const u8, title: ?[]const u8, expires_at: ?[]const u8, epoch: ?i64, }; -/// Renders the interactive RESET CREDITS detail block for one account and returns -/// the number of lines written. Lines are bounded to `max_cols` and terminated -/// with '\n'. Read-only: this view never redeems; redemption happens inside a -/// codex session when a rate limit is hit. -pub fn render( +/// 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, - writer: *std.Io.Writer, account: *const registry.AccountRecord, - max_cols: usize, - now: i64, -) !usize { - var cards: std.ArrayList(Card) = .empty; - defer cards.deinit(allocator); - var available: usize = 0; - var has_details = false; - - if (account.last_usage) |usage| { - if (usage.reset_credit_details) |details| { - has_details = true; - for (details.credits) |credit| { - if (!std.mem.eql(u8, credit.status, available_status)) continue; - available += 1; - try cards.append(allocator, .{ - .title = credit.title, - .expires_at = credit.expires_at, - .epoch = parseIsoEpoch(credit.expires_at orelse ""), - }); + 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_details orelse return cards.toOwnedSlice(allocator); + + for (details.credits) |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 = parseIsoEpoch(credit.expires_at orelse ""), + }); } - if (!has_details) available = 0; - if (account.last_usage) |usage| { - if (has_details) { - if (usage.reset_credits) |count| available = @intCast(@max(0, count)); - } - } - - std.mem.sort(Card, cards.items, {}, cardLessThan); + std.mem.sort(AvailableCard, cards.items, {}, cardLessThan); + return cards.toOwnedSlice(allocator); +} +/// Renders the read-only RESET CREDITS detail block. Lines are bounded to +/// `max_cols` and '\n'-terminated. `card_cursor` marks the selected credit +/// (cursor ▸ + a status/granted detail line); `confirm_pending` asks for the +/// second ⏎; `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, +) !usize { var lines: usize = 0; var header_buf: [128]u8 = undefined; - const header = std.fmt.bufPrint(&header_buf, "RESET CREDITS · {d} available", .{available}) catch "RESET CREDITS"; + const header = std.fmt.bufPrint(&header_buf, "RESET CREDITS · {d} available", .{cards.len}) catch "RESET CREDITS"; try writeRule(writer, "┌ ", header, max_cols, true); try writer.writeByte('\n'); lines += 1; - if (!has_details) { - try writeBounded(writer, "│ no reset-credit data · run `codex-auth list --api` to refresh", max_cols); - try writer.writeByte('\n'); - lines += 1; - } else if (cards.items.len == 0) { - try writeBounded(writer, "│ no available reset credits", max_cols); + if (cards.len == 0) { + try writeBounded(writer, "│ no available reset credits · run `codex-auth list --api` to refresh", max_cols); try writer.writeByte('\n'); lines += 1; } else { - for (cards.items) |card| { + for (cards, 0..) |card, index| { + const selected = index == card_cursor; var line: std.Io.Writer.Allocating = .init(allocator); defer line.deinit(); + if (selected) try line.writer.writeAll("▸ "); try line.writer.writeAll(card.title orelse "(untitled)"); if (card.expires_at) |expiry| { if (parseIsoEpoch(expiry)) |epoch| { @@ -82,14 +90,32 @@ pub fn render( } else { try line.writer.writeAll(" · no expiry"); } + if (selected and confirm_pending) try line.writer.writeAll(" · ⏎ again to use it"); try writer.writeAll("│ "); try writeBounded(writer, line.written(), max_cols); try writer.writeByte('\n'); lines += 1; + if (selected) { + var detail: std.Io.Writer.Allocating = .init(allocator); + defer detail.deinit(); + try detail.writer.writeAll(" status available"); + if (selected and confirm_pending) try detail.writer.writeAll(" · redeeming this resets the 5h/weekly windows"); + try writer.writeAll("│ "); + try writeBounded(writer, detail.written(), max_cols); + try writer.writeByte('\n'); + lines += 1; + } } } - try writeRule(writer, "└ ", "⏎ collapse", max_cols, false); + if (status_line) |message| { + try writer.writeAll("│ "); + try writeBounded(writer, message, max_cols); + try writer.writeByte('\n'); + lines += 1; + } + + try writeRule(writer, "└ ", "⏎ use · c collapse", max_cols, false); lines += 1; return lines; } @@ -112,7 +138,7 @@ pub fn spliceAfterDataLine(allocator: std.mem.Allocator, body: []const u8, data_ return allocator.dupe(u8, out.written()); } -fn cardLessThan(_: void, a: Card, b: Card) bool { +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; From 36380a6db19775fe6aba67aee00d617e729e95e8 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 22 Oct 2026 17:11:22 +0800 Subject: [PATCH 03/19] adapt: read the merged reset_credit flat array in the panel --- src/cli/reset_credits_panel.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli/reset_credits_panel.zig b/src/cli/reset_credits_panel.zig index 453a37e0..fde0bbd8 100644 --- a/src/cli/reset_credits_panel.zig +++ b/src/cli/reset_credits_panel.zig @@ -22,9 +22,9 @@ pub fn collectAvailable( errdefer cards.deinit(allocator); const usage = account.last_usage orelse return cards.toOwnedSlice(allocator); - const details = usage.reset_credit_details orelse return cards.toOwnedSlice(allocator); + const details = usage.reset_credit orelse return cards.toOwnedSlice(allocator); - for (details.credits) |credit| { + for (details) |credit| { if (!std.mem.eql(u8, credit.status, available_status)) continue; var consumed = false; for (consumed_ids) |used| { From 474c8dc4bc06e6e52c1b4310689452af04efd4ad Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 22 Oct 2026 17:23:23 +0800 Subject: [PATCH 04/19] fix: align reset-credit consume with upstream semantics - generate the redeem idempotency key from std.Io entropy (UUID v4) and reuse it while retrying the same credit, so the backend deduplicates instead of consuming a second credit - parse the consume response code (reset / already_redeemed / nothing_to_reset / no_credit) like upstream instead of trusting any 2xx status; already_redeemed counts as success and no_credit hides the spent card from the panel - restore workflow_dispatch in CI (unrelated accidental removal) --- .github/workflows/ci.yml | 1 + src/api/reset_credits.zig | 72 ++++++++++++++++++++++------ src/cli/live_view.zig | 98 ++++++++++++++++++++++++++++++--------- src/root.zig | 1 + tests/api_usage_test.zig | 44 ++++++++++++++++++ 5 files changed, 181 insertions(+), 35 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09229950..61d4b43d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ name: CI on: + workflow_dispatch: push: branches: - "main" diff --git a/src/api/reset_credits.zig b/src/api/reset_credits.zig index a64dc320..d3e74c42 100644 --- a/src/api/reset_credits.zig +++ b/src/api/reset_credits.zig @@ -7,8 +7,25 @@ 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 { - ok: bool, + outcome: ConsumeOutcome, status_code: ?u16 = null, error_name: ?[]const u8 = null, body: ?[]const u8 = null, @@ -20,13 +37,33 @@ pub const ConsumeResult = struct { } }; +/// 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`. -fn randomIdempotencyKeyAlloc(allocator: std.mem.Allocator) ![]u8 { +/// 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; - const nanos = std.Io.Timestamp.now(app_runtime.io(), .real).toNanoseconds(); - var prng = std.Random.DefaultPrng.init(@truncate(@as(u96, @bitCast(nanos)))); - prng.random().bytes(&raw); + app_runtime.io().random(&raw); raw[6] = (raw[6] & 0x0f) | 0x40; raw[8] = (raw[8] & 0x3f) | 0x80; const hex = "0123456789abcdef"; @@ -47,7 +84,7 @@ fn randomIdempotencyKeyAlloc(allocator: std.mem.Allocator) ![]u8 { fn fail(allocator: std.mem.Allocator, name: []const u8) ConsumeResult { return .{ - .ok = false, + .outcome = .request_error, .status_code = null, .error_name = allocator.dupe(u8, name) catch null, .body = null, @@ -55,11 +92,13 @@ fn fail(allocator: std.mem.Allocator, name: []const u8) ConsumeResult { } /// Redeems one reset credit for the given account via the backend consume -/// endpoint. The account must be a ChatGPT-auth account with a usable token. +/// 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); @@ -72,12 +111,10 @@ pub fn consumeCreditForAccount( 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 idempotency_key = try randomIdempotencyKeyAlloc(allocator); - defer allocator.free(idempotency_key); const body = try std.fmt.allocPrint( allocator, "{{\"redeem_request_id\":\"{s}\",\"credit_id\":\"{s}\"}}", - .{ idempotency_key, credit_id }, + .{ redeem_request_id, credit_id }, ); defer allocator.free(body); @@ -89,11 +126,18 @@ pub fn consumeCreditForAccount( body, ) catch |err| return fail(allocator, @errorName(err)); - const ok = http_result.status_code != null and - http_result.status_code.? >= 200 and http_result.status_code.? < 300; + 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 .{ - .ok = ok, - .status_code = http_result.status_code, + .outcome = parseConsumeOutcome(allocator, http_result.body), + .status_code = status_code, .error_name = null, .body = http_result.body, }; diff --git a/src/cli/live_view.zig b/src/cli/live_view.zig index 45d541b8..a309153e 100644 --- a/src/cli/live_view.zig +++ b/src/cli/live_view.zig @@ -277,6 +277,8 @@ pub fn viewAccountsWithLiveUpdates( var panel_status: ?[]u8 = null; var selected_credit_id: ?[]u8 = null; var selected_account_key: ?[]u8 = null; + var redeem_key: ?[]u8 = null; + var redeem_credit_id: ?[]u8 = null; var available_count_cache: usize = 0; var consumed_ids: std.ArrayList([]u8) = .empty; defer { @@ -285,6 +287,8 @@ pub fn viewAccountsWithLiveUpdates( if (panel_status) |status| allocator.free(status); 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) { @@ -450,29 +454,81 @@ pub fn viewAccountsWithLiveUpdates( if (pending_consume) { pending_consume = false; if (selected_credit_id != null and selected_account_key != null) { - var result: reset_credits_api.ConsumeResult = blk: { - const codex_home = registry.resolveCodexHome(allocator) catch |err| { - break :blk reset_credits_api.ConsumeResult{ .ok = false, .error_name = try allocator.dupe(u8, @errorName(err)) }; + // 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| { + var result: reset_credits_api.ConsumeResult = blk: { + const codex_home = registry.resolveCodexHome(allocator) catch |err| { + break :blk reset_credits_api.ConsumeResult{ .outcome = .request_error, .error_name = try allocator.dupe(u8, @errorName(err)) }; + }; + defer allocator.free(codex_home); + break :blk 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 allocator.free(codex_home); - break :blk reset_credits_api.consumeCreditForAccount( - allocator, - codex_home, - selected_account_key.?, - selected_credit_id.?, - ) catch |err| reset_credits_api.ConsumeResult{ .ok = false, .error_name = try allocator.dupe(u8, @errorName(err)) }; - }; - defer result.deinit(allocator); - if (result.ok) { - const id_copy = try allocator.dupe(u8, selected_credit_id.?); - try consumed_ids.append(allocator, id_copy); - try setPanelStatus(allocator, &panel_status, "✓ reset credit used · windows reset on next refresh"); - } else if (result.status_code) |code| { - const text = try std.fmt.allocPrint(allocator, "✗ use failed (HTTP {d})", .{code}); - defer allocator.free(text); - try setPanelStatus(allocator, &panel_status, text); + defer result.deinit(allocator); + switch (result.outcome) { + .reset, .already_redeemed, .no_credit => { + 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; + const message = switch (result.outcome) { + .reset => "✓ reset credit used · windows reset on next refresh", + .already_redeemed => "✓ reset credit was already used · windows reset on next refresh", + .no_credit => "✗ reset credit is no longer available · refresh to see current resets", + else => unreachable, + }; + try setPanelStatus(allocator, &panel_status, message); + }, + .nothing_to_reset => { + try setPanelStatus(allocator, &panel_status, "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 setPanelStatus(allocator, &panel_status, text); + } else { + try setPanelStatus(allocator, &panel_status, "✗ 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 setPanelStatus(allocator, &panel_status, text); + } else { + try setPanelStatus(allocator, &panel_status, "use failed"); + } + }, + .request_error => { + try setPanelStatus(allocator, &panel_status, result.error_name orelse "use failed"); + }, + } } else { - try setPanelStatus(allocator, &panel_status, result.error_name orelse "use failed"); + try setPanelStatus(allocator, &panel_status, "✗ use failed (idempotency key unavailable)"); } needs_render = true; } diff --git a/src/root.zig b/src/root.zig index cf9adf4c..28616553 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"); }; diff --git a/tests/api_usage_test.zig b/tests/api_usage_test.zig index b8e06da4..a63dae25 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,46 @@ 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 "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')); + } +} From 312d33208b08894a00341b94851bdd9ed4eadd3b Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 22 Oct 2026 17:29:05 +0800 Subject: [PATCH 05/19] feat: add a reset command for the soonest-expiring credit `codex-auth reset ` redeems the available reset credit with the earliest expiry for one account and prints the consumed card plus the credits that remain. It reuses the panel's card collection, the shared consume call, and the consume outcome mapping (reset / already_redeemed succeed; no_credit / nothing_to_reset fail with their own messages). Help, command docs, and README entries are included, along with scenario tests for the success and no-credit paths. --- README.md | 1 + docs/commands/README.md | 1 + docs/commands/reset.md | 23 ++++++ src/cli/commands/reset.zig | 27 +++++++ src/cli/commands/root.zig | 4 ++ src/cli/help.zig | 21 +++++- src/cli/output.zig | 55 ++++++++++++++ src/cli/root.zig | 1 + src/cli/types.zig | 5 ++ src/workflows/preflight.zig | 1 + src/workflows/reset.zig | 103 ++++++++++++++++++++++++++ src/workflows/root.zig | 2 + tests/cli_integration_test.zig | 127 +++++++++++++++++++++++++++++++++ 13 files changed, 369 insertions(+), 2 deletions(-) create mode 100644 docs/commands/reset.md create mode 100644 src/cli/commands/reset.zig create mode 100644 src/workflows/reset.zig 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/reset.md b/docs/commands/reset.md new file mode 100644 index 00000000..757570fa --- /dev/null +++ b/docs/commands/reset.md @@ -0,0 +1,23 @@ +# `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 the consumed credit (title and local expiry) and the reset credits that remain for the account. +- 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/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/output.zig b/src/cli/output.zig index c2a4f999..6272a1f8 100644 --- a/src/cli/output.zig +++ b/src/cli/output.zig @@ -397,6 +397,61 @@ 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 (title and local expiry). +pub fn printResetConsumed(email: []const u8, 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("reset credit was already used for {s}: \"{s}\"", .{ email, title orelse "(untitled)" }); + } else { + try out.print("used reset credit for {s}: \"{s}\"", .{ email, title orelse "(untitled)" }); + } + if (expires_local) |expiry| { + try out.print(" \u{00b7} expires {s}", .{expiry}); + } + try out.writeAll("\n"); + try out.flush(); +} + +/// Prints the reset credits that remain after the consume call. +pub fn printResetRemaining(email: []const u8, entries: []const ResetRemainingEntry) !void { + var stdout: io_util.Stdout = undefined; + stdout.init(); + const out = stdout.out(); + try out.print("Remaining reset credits for {s} ({d}):\n", .{ email, entries.len }); + if (entries.len == 0) { + try out.writeAll(" (none)\n"); + } else { + for (entries) |entry| { + try out.print(" \u{00b7} {s}", .{entry.title orelse "(untitled)"}); + if (entry.expires_local) |expiry| { + try out.print(" \u{00b7} expires {s}", .{expiry}); + } + try out.writeAll("\n"); + } + } + try out.flush(); +} + pub fn printInvalidRemoveSelectionError() !void { var stderr: io_util.Stderr = undefined; stderr.init(); diff --git a/src/cli/root.zig b/src/cli/root.zig index 02003908..8b0a1e1f 100644 --- a/src/cli/root.zig +++ b/src/cli/root.zig @@ -8,5 +8,6 @@ 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 tui = @import("tui.zig"); 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/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..4812bda5 --- /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(rec.email, 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(rec.email, 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/cli_integration_test.zig b/tests/cli_integration_test.zig index 75e2b6f8..5d1c9246 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 reset credit for reset-user@example.com") != 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 reset credits for reset-user@example.com (1)") != 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); +} From 45132988f3fa8610828a8bc4d05f931d88b27a79 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 22 Oct 2026 17:33:06 +0800 Subject: [PATCH 06/19] feat: compact reset command output Print the consumed credit on one line (title + local expiry) and replace the per-card remaining list with a single summary line: count plus the card that expires next. Matches the two-line format requested for the command. --- docs/commands/reset.md | 7 ++++++- src/cli/output.zig | 29 +++++++++++++++-------------- src/workflows/reset.zig | 4 ++-- tests/cli_integration_test.zig | 4 ++-- 4 files changed, 25 insertions(+), 19 deletions(-) diff --git a/docs/commands/reset.md b/docs/commands/reset.md index 757570fa..0205fa8d 100644 --- a/docs/commands/reset.md +++ b/docs/commands/reset.md @@ -13,7 +13,12 @@ codex-auth reset - 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 the consumed credit (title and local expiry) and the reset credits that remain for the account. +- 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) · no expiry + ``` - 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. diff --git a/src/cli/output.zig b/src/cli/output.zig index 6272a1f8..6d65fbea 100644 --- a/src/cli/output.zig +++ b/src/cli/output.zig @@ -415,15 +415,15 @@ pub fn printResetError(comptime fmt: []const u8, args: anytype) !void { try out.flush(); } -/// Prints the consumed reset credit (title and local expiry). -pub fn printResetConsumed(email: []const u8, title: ?[]const u8, expires_local: ?[]const u8, already_used: bool) !void { +/// 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("reset credit was already used for {s}: \"{s}\"", .{ email, title orelse "(untitled)" }); + try out.print("already used \"{s}\"", .{title orelse "(untitled)"}); } else { - try out.print("used reset credit for {s}: \"{s}\"", .{ email, title orelse "(untitled)" }); + try out.print("used \"{s}\"", .{title orelse "(untitled)"}); } if (expires_local) |expiry| { try out.print(" \u{00b7} expires {s}", .{expiry}); @@ -432,22 +432,23 @@ pub fn printResetConsumed(email: []const u8, title: ?[]const u8, expires_local: try out.flush(); } -/// Prints the reset credits that remain after the consume call. -pub fn printResetRemaining(email: []const u8, entries: []const ResetRemainingEntry) !void { +/// 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(); - try out.print("Remaining reset credits for {s} ({d}):\n", .{ email, entries.len }); if (entries.len == 0) { - try out.writeAll(" (none)\n"); + try out.writeAll("remaining: 0\n"); } else { - for (entries) |entry| { - try out.print(" \u{00b7} {s}", .{entry.title orelse "(untitled)"}); - if (entry.expires_local) |expiry| { - try out.print(" \u{00b7} expires {s}", .{expiry}); - } - try out.writeAll("\n"); + 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} no expiry"); } + try out.writeAll("\n"); } try out.flush(); } diff --git a/src/workflows/reset.zig b/src/workflows/reset.zig index 4812bda5..5437ea32 100644 --- a/src/workflows/reset.zig +++ b/src/workflows/reset.zig @@ -58,7 +58,7 @@ pub fn handleReset(allocator: std.mem.Allocator, codex_home: []const u8, opts: c else null; defer if (expires_local) |text| allocator.free(text); - try cli.output.printResetConsumed(rec.email, card.title, expires_local, result.outcome == .already_redeemed); + try cli.output.printResetConsumed(card.title, expires_local, result.outcome == .already_redeemed); var remaining = std.ArrayList(cli.output.ResetRemainingEntry).empty; defer { @@ -77,7 +77,7 @@ pub fn handleReset(allocator: std.mem.Allocator, codex_home: []const u8, opts: c .expires_local = remaining_expiry, }); } - try cli.output.printResetRemaining(rec.email, remaining.items); + 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.", .{}); diff --git a/tests/cli_integration_test.zig b/tests/cli_integration_test.zig index 5d1c9246..d7cb4338 100644 --- a/tests/cli_integration_test.zig +++ b/tests/cli_integration_test.zig @@ -5277,9 +5277,9 @@ test "Scenario: Given reset credits when running reset then it consumes the soon logRunResultIfFailed("reset", result); try expectSuccess(result); - try std.testing.expect(std.mem.indexOf(u8, result.stdout, "used reset credit for reset-user@example.com") != null); + 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 reset credits for reset-user@example.com (1)") != 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); } From 94b93207fdbb4cdd3c52e44a0dc181831b8f3f17 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 22 Oct 2026 17:39:03 +0800 Subject: [PATCH 07/19] feat: render a missing reset-credit expiry as - The RESET CREDITS cell now reads N (-) when no available credit has an expiry, and the reset command's remaining summary prints - instead of 'no expiry'. Docs updated to match. --- docs/commands/list.md | 2 +- docs/commands/reset.md | 2 +- src/cli/list_details.zig | 2 +- src/cli/output.zig | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/commands/list.md b/docs/commands/list.md index 4e0f7d47..c30867a8 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -47,7 +47,7 @@ When local-only refresh is active, only the active account can be updated from l - Grouped rows keep the shared email in the header; child rows with both alias and account name render as `alias(account name)`. - Usage cells show remaining percent and reset time when that data is known. - In non-live output, `CREDITS` shows the integer part of the current `credits.balance` value. -- `RESET CREDITS` remains a separate field that shows the stored reset-credit count when remote usage refresh provides it. +- `RESET CREDITS` remains a separate field that shows the stored reset-credit count when remote usage refresh provides it. The cell appends the earliest available credit expiry as `N (exp MMM-DD)`, or `N (-)` when no available credit has an expiry. - JSON output includes the returned reset-credit details. - 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. diff --git a/docs/commands/reset.md b/docs/commands/reset.md index 0205fa8d..54c4208d 100644 --- a/docs/commands/reset.md +++ b/docs/commands/reset.md @@ -17,7 +17,7 @@ codex-auth reset ```text used "Full reset (Weekly + 5 hr)" · expires 2026-11-01 08:00 - remaining: 1 · next: (untitled) · no expiry + 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. diff --git a/src/cli/list_details.zig b/src/cli/list_details.zig index 559cef20..608703b2 100644 --- a/src/cli/list_details.zig +++ b/src/cli/list_details.zig @@ -89,7 +89,7 @@ fn resetCreditsCell(allocator: std.mem.Allocator, account: registry.AccountRecor defer allocator.free(local); if (local.len >= 10) return std.fmt.allocPrint(allocator, "{d} (exp {s})", .{ value, local[5..10] }); } - return std.fmt.allocPrint(allocator, "{d}", .{value}); + return std.fmt.allocPrint(allocator, "{d} (-)", .{value}); } fn visibleBaseColumns(max_cols: ?usize, number_width: usize) [base_column_count]bool { diff --git a/src/cli/output.zig b/src/cli/output.zig index 6d65fbea..eee3a8bc 100644 --- a/src/cli/output.zig +++ b/src/cli/output.zig @@ -446,7 +446,7 @@ pub fn printResetRemaining(entries: []const ResetRemainingEntry) !void { if (next.expires_local) |expiry| { try out.print(" \u{00b7} {s}", .{expiry}); } else { - try out.writeAll(" \u{00b7} no expiry"); + try out.writeAll(" \u{00b7} -"); } try out.writeAll("\n"); } From be85b0b36797435b4e6996e6e67e6ee543e2cc01 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 22 Oct 2026 17:39:28 +0800 Subject: [PATCH 08/19] test: cover the N (-) reset credits cell for undated cards --- tests/cli_picker_test.zig | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/cli_picker_test.zig b/tests/cli_picker_test.zig index b6234050..34ed8380 100644 --- a/tests/cli_picker_test.zig +++ b/tests/cli_picker_test.zig @@ -1704,6 +1704,32 @@ 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 "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 keeps the active marker visible for double-digit accounts" { const gpa = std.testing.allocator; var reg = makeTestRegistry(); From e4bf3e03ab95d6bdc354b41de69e0b74738d7a09 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 22 Oct 2026 17:43:09 +0800 Subject: [PATCH 09/19] fix(list): unify responsive column allocation with live details Keep each view's visual column order while sharing one allocator. Plain list, live details, switch, and remove now hide unsupported fields and redistribute their width/separators. Use width-aware percentage cells, compact AUTH EXPIRE/RESET CREDITS headers, and leave ACCOUNT growth last. Add coverage for live-detail widths, 100% completeness, and column order. --- docs/commands/list.md | 2 +- docs/table-layout.md | 95 ++++++++------- src/cli/list_details.zig | 245 ++++++++++++++++++-------------------- src/cli/render.zig | 6 +- src/cli/table_layout.zig | 190 +++++++++++++++++++---------- src/tui/table.zig | 13 +- tests/cli_picker_test.zig | 43 +++++++ 7 files changed, 350 insertions(+), 244 deletions(-) diff --git a/docs/commands/list.md b/docs/commands/list.md index c30867a8..9a0dbdc9 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -29,7 +29,7 @@ codex-auth list --json - In live tables, `*` before the number marks the active account and `!` marks a refresh error. - Live account details show the access-token expiry and, per account, the available reset-credit count with the earliest card expiry. The - `ACCESS EXPIRE` and `RESET CREDITS` columns render in local time; the + `AUTH EXPIRE` and `RESET CREDITS` columns render in local time; the available reset credits are also expandable per account in the live view. - Access-token expiry is shown in local time and is not the account or subscription expiry; refreshing credentials can extend it. Plan expiry is shown as unavailable diff --git a/docs/table-layout.md b/docs/table-layout.md index b03806eb..b9e6ff98 100644 --- a/docs/table-layout.md +++ b/docs/table-layout.md @@ -1,68 +1,79 @@ # Table Layout -Every table view uses the same responsive allocator in -`src/cli/table_layout.zig`. The full column model is: +Every table view uses one responsive allocator. Each view supplies its own +visual column order and the fields it supports; unsupported fields are hidden by +passing a zero content width. -`ACCOUNT`, `PLAN`, `CREDITS`, `RESET CREDITS`, `5H`, `WEEKLY`, `LAST ACTIVITY` +| View | Visible columns | +|---|---| +| `list` | `ACCOUNT`, `PLAN`, `CREDITS`, `RESET CREDITS`, `5H`, `WEEKLY`, `LAST ACTIVITY` | +| `list --live` | `#*`, `ACCOUNT`, `PLAN`, `5H`, `WEEKLY`, `LAST`, `AUTH EXPIRE`, `RESET CREDITS` | +| `switch`, `remove` | `ACCOUNT`, `PLAN`, `5H`, `WEEKLY`, `LAST` | + +Visual order never changes when columns are hidden. Width and separator budget +from a hidden column are redistributed to the remaining columns. -The plain `list` table enables all seven columns. The live views -(`switch`, `remove`, and `list --live`) disable `CREDITS` and -`RESET CREDITS`: those columns are absent from the layout, and their width and -separator budget is redistributed to the remaining columns. +## Allocation -## Column Allocation +The allocator receives each column's content width and the terminal width. +Columns reserve width in priority order; a column is skipped when its target +width and separator do not fit. `ACCOUNT` may use a partial width down to 2 +columns. Columns with width `0` are skipped entirely, including their headers, +cells, and separators. -The allocator receives the content width of each column and the available -terminal width. A column with content width `0` is treated as disabled. +### Plain `list` -### First pass: reserve useful targets by priority +Reserve order: ```text -ACCOUNT (10) - -> 5H (4) - -> WEEKLY (6) - -> CREDITS (2) - -> PLAN (4) - -> RESET CREDITS (2) - -> LAST (4) +ACCOUNT (10) -> 5H (4) -> WEEKLY (6) -> CREDITS (2) + -> PLAN (4) -> RESET CREDITS (2) -> LAST (4) ``` -- Separators are charged only for columns that are actually visible. -- A non-account column is shown only when its target width and separator fit. -- `ACCOUNT` may use a partial width down to 2 columns when the terminal is - very narrow. -- A column that cannot receive a useful width is hidden instead of being - rendered as a one-column `.` cell. +Grow order: -### Second pass: grow toward content width +```text +CREDITS -> 5H -> WEEKLY -> PLAN -> RESET CREDITS -> LAST -> ACCOUNT +``` -Visible columns grow in this order: +### `list --live` + +Reserve order: ```text -CREDITS -> 5H -> WEEKLY -> PLAN -> RESET CREDITS -> LAST -> ACCOUNT +#* -> ACCOUNT (10) -> 5H (4) -> WEEKLY (6) + -> PLAN (4) -> LAST (4) -> AUTH EXPIRE (10) -> RESET CREDITS (2) ``` -The credit cell is grown first so values such as `unlimited` do not collapse to -`u.`. Usage and error-name cells grow before `LAST`, and `ACCOUNT` grows last so -a long account label cannot starve the values beside it. In the live views, -`CREDITS` and `RESET CREDITS` are disabled, so the effective order is: +Grow order: ```text -5H -> WEEKLY -> PLAN -> LAST -> ACCOUNT +AUTH EXPIRE -> RESET CREDITS -> PLAN -> LAST -> 5H -> WEEKLY -> ACCOUNT ``` -Columns with width `0` are skipped entirely, including their headers, cells, -and separators. +### `switch`, `remove` + +Reserve order: + +```text +ACCOUNT (10) -> 5H (4) -> WEEKLY (6) -> PLAN (4) -> LAST (4) +``` + +Grow order: + +```text +5H -> WEEKLY -> PLAN -> LAST -> ACCOUNT +``` ## Headers -Wide headers use their full label. When a column is narrower than its full -label, compact labels are used: +Wide headers use their full label. Narrow columns use compact labels: -| Column | Compact | +| Column | Compact labels | |---|---| | `CREDITS` | `CR` | | `RESET CREDITS` | `RESET`, then `RC` | +| `AUTH EXPIRE` | `AUTH EXP`, then `AUTH` | ## Account Truncation @@ -92,7 +103,7 @@ label is truncated. ## Examples -Plain `list`, where all seven columns are enabled: +Plain `list`: ```text ACCOUNT PLAN CREDITS RC 5H WEEKLY LAST @@ -101,11 +112,9 @@ very-long. Pro - - 100% 58% - work-main. Pro unlimited 3 31% 42% - ``` -Live view, where `CREDITS` and `RESET CREDITS` are disabled and their space is -redistributed: +`list --live` at a medium width: ```text -ACCOUNT PLAN 5H WEEKLY ------------------------------------ -very-l..com Business 100% 42% +#* | ACCOUNT | PLAN | 5H | WEEKLY + 1 | very-l.om) | Pro | 100% | 58% ``` diff --git a/src/cli/list_details.zig b/src/cli/list_details.zig index 608703b2..b97f8649 100644 --- a/src/cli/list_details.zig +++ b/src/cli/list_details.zig @@ -3,28 +3,54 @@ const registry = @import("../registry/root.zig"); const rows = @import("rows.zig"); const style = @import("style.zig"); const table_layout = @import("table_layout.zig"); +const text_width = @import("../tui/text_width.zig"); -const base_headers = [_][]const u8{ "#*", "ACCOUNT", "PLAN", "5H", "WEEKLY", "LAST", "AUTH EXPIRE", "RESET CREDITS" }; -const base_desired_widths = [_]usize{ 2, 18, 8, 12, 12, 10, 16, 14 }; -const base_minimum_widths = [_]usize{ 1, 8, 4, 4, 6, 5, 10, 8 }; -const base_column_count = base_headers.len; const available_status = "available"; const separator_width: usize = 3; +const column_count = table_layout.list_detail_columns.len; -pub fn writeHeader(writer: *style.StyledWriter, number_width: usize, max_cols: ?usize) !void { - const visible_base = visibleBaseColumns(max_cols, number_width); - var values: [base_column_count][]const u8 = undefined; - var widths: [base_column_count]usize = undefined; - var minimums: [base_column_count]usize = undefined; - var count: usize = 0; - for (base_headers, 0..) |value, index| { - if (!visible_base[index]) continue; - values[count] = value; - widths[count] = if (index == 0) number_width else base_desired_widths[index]; - minimums[count] = if (index == 0) number_width else base_minimum_widths[index]; - count += 1; +pub fn computeWidths( + number_width: usize, + widths: rows.SwitchWidths, + max_cols: ?usize, +) [column_count]usize { + const columns = [_]table_layout.ColumnSpec{ + .{ + .id = .number, + .content_width = @max(number_width, "#*".len), + .target_width = @max(number_width, "#*".len), + .min_width = @max(number_width, "#*".len), + }, + table_layout.columnSpec(.account, @max(widths.email, "ACCOUNT".len)), + table_layout.columnSpec(.plan, @max(widths.plan, "PLAN".len)), + 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(.last, @max(widths.last, "LAST".len)), + table_layout.columnSpec(.auth_expire, @max("AUTH EXPIRE".len, 16)), + table_layout.columnSpec(.reset_credits, @max("RESET CREDITS".len, 14)), + }; + var bounded = [_]usize{0} ** column_count; + table_layout.allocateColumns(.{ + .columns = &columns, + .first_order = &table_layout.list_detail_first_order, + .grow_order = &table_layout.list_detail_grow_order, + .prefix_width = 0, + .separator_width = separator_width, + }, max_cols, &bounded); + return bounded; +} + +pub fn writeHeader(writer: *style.StyledWriter, widths: [column_count]usize) !void { + try writer.writeStyle(style.role.status); + var wrote_any = false; + for (table_layout.list_detail_columns, 0..) |id, index| { + if (widths[index] == 0) continue; + if (wrote_any) try writer.out.writeAll(" | "); + wrote_any = true; + try table_layout.writeTruncatedPadded(writer.out, headerFor(id, widths[index]), widths[index]); } - try writeAlignedRow(writer, values[0..count], widths[0..count], minimums[0..count], max_cols, false, style.role.status, true); + try writer.reset(); + try writer.out.writeByte('\n'); } pub fn write( @@ -32,30 +58,83 @@ pub fn write( writer: *style.StyledWriter, account: registry.AccountRecord, row: anytype, - number_width: usize, - max_cols: ?usize, + widths: [column_count]usize, ) !void { - const visible_base = visibleBaseColumns(max_cols, number_width); const token_expiry = try rows.formatTimestampAlloc(allocator, account.access_token_expires_at); defer allocator.free(token_expiry); - const reset_credits_cell = try resetCreditsCell(allocator, account); - defer allocator.free(reset_credits_cell); + const reset_full = try resetCreditsCell(allocator, account); + defer allocator.free(reset_full); + const reset_short = shortResetCell(reset_full); - const number = row.number; - var values: [base_column_count][]const u8 = undefined; - var widths: [base_column_count]usize = undefined; - var minimums: [base_column_count]usize = undefined; - var count: usize = 0; - const base_values = [_][]const u8{ number, row.account, row.plan, row.rate_5h, row.rate_week, row.last, token_expiry, reset_credits_cell }; - for (base_values, 0..) |value, index| { - if (!visible_base[index]) continue; - values[count] = value; - widths[count] = if (index == 0) number_width else base_desired_widths[index]; - minimums[count] = if (index == 0) number_width else base_minimum_widths[index]; - count += 1; + const values = [_][]const u8{ + row.number, + row.account, + row.plan, + row.rate_5h, + row.rate_week, + row.last, + token_expiry, + reset_full, + }; + const row_style = if (row.number.len != 0 and row.number[0] == '!') style.role.error_text else if (row.number.len != 0 and row.number[0] == '*') style.role.success else ""; + try writer.writeStyle(row_style); + var wrote_any = false; + for (table_layout.list_detail_columns, 0..) |id, index| { + if (widths[index] == 0) continue; + if (wrote_any) { + if (row_style.len == 0) try writer.writeStyle(style.role.secondary); + try writer.out.writeAll(" | "); + if (row_style.len == 0) try writer.reset(); + try writer.writeStyle(row_style); + } + wrote_any = true; + switch (id) { + .account => try table_layout.writeAccountTruncatedPadded(writer.out, values[index], widths[index]), + .rate_5h, .rate_week => try writeUsageCell(writer.out, values[index], widths[index]), + .reset_credits => try writeResetCell(writer.out, reset_full, reset_short, widths[index]), + else => try table_layout.writeTruncatedPadded(writer.out, values[index], widths[index]), + } } - const row_style = if (number.len != 0 and number[0] == '!') style.role.error_text else if (number.len != 0 and number[0] == '*') style.role.success else ""; - try writeAlignedRow(writer, values[0..count], widths[0..count], minimums[0..count], max_cols, true, row_style, false); + try writer.reset(); + try writer.out.writeByte('\n'); +} + +fn headerFor(id: table_layout.ColumnId, width: usize) []const u8 { + return switch (id) { + .number => "#*", + .account => "ACCOUNT", + .plan => "PLAN", + .credits => "CREDITS", + .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", + .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)], + }; +} + +fn writeUsageCell(out: *std.Io.Writer, value: []const u8, width: usize) !void { + if (std.mem.indexOfScalar(u8, value, '%')) |percent| { + const short = value[0 .. percent + 1]; + if (text_width.displayWidth(value) > width and text_width.displayWidth(short) <= width) { + try table_layout.writeTruncatedPadded(out, short, width); + return; + } + } + try table_layout.writeTruncatedPadded(out, value, width); +} + +fn writeResetCell(out: *std.Io.Writer, full: []const u8, short: []const u8, width: usize) !void { + if (text_width.displayWidth(full) > width and width < 5 and text_width.displayWidth(short) <= width) { + try table_layout.writeTruncatedPadded(out, short, width); + return; + } + try table_layout.writeTruncatedPadded(out, full, width); +} + +fn shortResetCell(full: []const u8) []const u8 { + if (std.mem.indexOf(u8, full, " (")) |index| return full[0..index]; + return full; } /// `N` from the persisted `reset_credits` count, plus the earliest expiry among @@ -92,100 +171,6 @@ fn resetCreditsCell(allocator: std.mem.Allocator, account: registry.AccountRecor return std.fmt.allocPrint(allocator, "{d} (-)", .{value}); } -fn visibleBaseColumns(max_cols: ?usize, number_width: usize) [base_column_count]bool { - var visible: [base_column_count]bool = undefined; - for (&visible) |*value| value.* = true; - const limit = max_cols orelse return visible; - const drop_order = [_]usize{ 7, 6, 5, 2 }; - while (minimumTableWidth(visible, number_width) > limit) { - var changed = false; - for (drop_order) |index| { - if (visible[index]) { - visible[index] = false; - changed = true; - break; - } - } - if (!changed) break; - } - return visible; -} - -fn minimumTableWidth(visible: [base_column_count]bool, number_width: usize) usize { - var total: usize = 0; - var column_count: usize = 0; - for (base_minimum_widths, 0..) |width, index| { - if (!visible[index]) continue; - total += if (index == 0) @max(width, number_width) else width; - column_count += 1; - } - return if (column_count == 0) 0 else total + (column_count - 1) * separator_width; -} - -fn writeAlignedRow( - writer: *style.StyledWriter, - values: []const []const u8, - desired: []const usize, - minimums: []const usize, - max_cols: ?usize, - account_column: bool, - row_style: []const u8, - header: bool, -) !void { - var widths: [base_column_count]usize = undefined; - for (desired, 0..) |width, index| widths[index] = width; - if (max_cols) |limit| { - var total = minimumForWidths(widths[0..values.len]); - while (total > limit) { - var changed = false; - var index = values.len; - while (index > 0) { - index -= 1; - if (widths[index] > minimums[index]) { - widths[index] -= 1; - total -= 1; - changed = true; - break; - } - } - if (changed) continue; - - // If the required columns still do not fit, keep the frame inside - // the terminal rather than allowing a hard wrap. - for (widths[0..values.len]) |*width| { - if (total <= limit) break; - if (width.* > 1) { - width.* -= 1; - total -= 1; - } - } - if (total > limit) break; - } - } - try writer.writeStyle(row_style); - for (values, 0..) |value, index| { - if (index != 0) { - if (!header and row_style.len == 0) try writer.writeStyle(style.role.secondary); - try writer.writeAll(" | "); - if (!header and row_style.len == 0) try writer.reset(); - try writer.writeStyle(row_style); - } - if (account_column and index == 1) { - try table_layout.writeAccountTruncatedPadded(writer.out, value, widths[index]); - } else { - try table_layout.writeTruncatedPadded(writer.out, value, widths[index]); - } - } - try writer.reset(); - try writer.writeAll("\n"); -} - -fn minimumForWidths(widths: []const usize) usize { - var total: usize = 0; - for (widths) |width| total += width; - return total + (widths.len - 1) * separator_width; -} - /// Minimal ISO-8601 parser for the backend shape `YYYY-MM-DDTHH:MM:SS` with an /// optional trailing zone (`Z` or `±HH:MM`). Returns epoch seconds, or null. fn parseIsoEpoch(value: []const u8) ?i64 { diff --git a/src/cli/render.zig b/src/cli/render.zig index 22024f40..0778dbe2 100644 --- a/src/cli/render.zig +++ b/src/cli/render.zig @@ -422,11 +422,11 @@ pub fn renderListBody( widths: SwitchWidths, max_cols: ?usize, ) !void { - _ = widths; const details = @import("list_details.zig"); const number_width = idx_width + 1; - try details.writeHeader(writer, number_width, max_cols); + const column_widths = details.computeWidths(number_width, widths, max_cols); + try details.writeHeader(writer, column_widths); var displayed_counter: usize = 0; for (rows) |row| { if (row.is_header) continue; @@ -444,7 +444,7 @@ pub fn renderListBody( .rate_5h = row.rate_5h, .rate_week = row.rate_week, .last = row.last, - }, number_width, max_cols); + }, column_widths); } } diff --git a/src/cli/table_layout.zig b/src/cli/table_layout.zig index 70b54740..affa1c15 100644 --- a/src/cli/table_layout.zig +++ b/src/cli/table_layout.zig @@ -12,7 +12,6 @@ pub const LiveListViewport = struct { }; pub const live_column_count = 5; -pub const column_count = 7; const live_account_ident_width: usize = 10; const account_min_width: usize = 2; const live_account_suffix_min_width: usize = 10; @@ -29,6 +28,37 @@ pub const Cell = struct { indent: usize = 0, }; +/// Semantic identity of a responsive table column. Visual order is supplied +/// separately, so views such as `list` and `list --live` can keep their own +/// column order while sharing one allocation strategy. +pub const ColumnId = enum { + number, + account, + plan, + credits, + reset_credits, + rate_5h, + rate_week, + last, + auth_expire, +}; + +pub const ColumnSpec = struct { + id: ColumnId, + content_width: usize, + target_width: usize, + min_width: usize, + allow_partial: bool = false, +}; + +pub const ColumnLayout = struct { + columns: []const ColumnSpec, + first_order: []const ColumnId, + grow_order: []const ColumnId, + prefix_width: usize, + separator_width: usize, +}; + pub const LiveTable = struct { columns: [live_column_count]LiveTableColumn, prefix_width: usize, @@ -94,98 +124,126 @@ pub const LiveTable = struct { } }; -pub fn accountTable(widths: [column_count]usize, prefix_width: usize) LiveTable { +pub fn accountTable(widths: [live_column_count]usize, prefix_width: usize) LiveTable { return .{ .columns = .{ .{ .header = "ACCOUNT", .width = widths[0] }, .{ .header = "PLAN", .width = widths[1] }, - .{ .header = "5H", .width = widths[4] }, - .{ .header = "WEEKLY", .width = widths[5] }, - .{ .header = "LAST", .width = widths[6] }, + .{ .header = "5H", .width = widths[2] }, + .{ .header = "WEEKLY", .width = widths[3] }, + .{ .header = "LAST", .width = widths[4] }, }, .prefix_width = prefix_width, }; } +pub const switch_columns = [_]ColumnId{ .account, .plan, .rate_5h, .rate_week, .last }; +pub const switch_first_order = [_]ColumnId{ .account, .rate_5h, .rate_week, .plan, .last }; +pub const switch_grow_order = [_]ColumnId{ .rate_5h, .rate_week, .plan, .last, .account }; + pub fn accountTableForSwitch(widths: SwitchWidths, prefix_width: usize, max_cols: ?usize) LiveTable { - return accountTable( - boundColumnWidths(switchWidthsToColumns(widths), prefix_width, max_cols), - prefix_width, - ); + const source_widths = [_]usize{ widths.email, widths.plan, widths.rate_5h, widths.rate_week, widths.last }; + var columns: [switch_columns.len]ColumnSpec = undefined; + for (switch_columns, 0..) |id, index| { + columns[index] = columnSpec(id, source_widths[index]); + } + var bounded = [_]usize{0} ** live_column_count; + allocateColumns(.{ + .columns = &columns, + .first_order = &switch_first_order, + .grow_order = &switch_grow_order, + .prefix_width = prefix_width, + .separator_width = 2, + }, max_cols, &bounded); + return accountTable(bounded, prefix_width); } -const first_pass_order = [_]usize{ 0, 4, 5, 2, 1, 3, 6 }; -const grow_pass_order = [_]usize{ 2, 4, 5, 1, 3, 6, 0 }; -const column_target_widths = [_]usize{ - live_account_ident_width, // ACCOUNT - "PLAN".len, // PLAN - 2, // CREDITS - 2, // RESET CREDITS - @max(4, "5H".len), // 5H - "WEEKLY".len, // WEEKLY - 4, // LAST -}; +pub const plain_columns = [_]ColumnId{ .account, .plan, .credits, .reset_credits, .rate_5h, .rate_week, .last }; +pub const plain_first_order = [_]ColumnId{ .account, .rate_5h, .rate_week, .credits, .plan, .reset_credits, .last }; +pub const plain_grow_order = [_]ColumnId{ .credits, .rate_5h, .rate_week, .plan, .reset_credits, .last, .account }; + +pub const list_detail_columns = [_]ColumnId{ .number, .account, .plan, .rate_5h, .rate_week, .last, .auth_expire, .reset_credits }; +pub const list_detail_first_order = [_]ColumnId{ .number, .account, .rate_5h, .rate_week, .plan, .last, .auth_expire, .reset_credits }; +pub const list_detail_grow_order = [_]ColumnId{ .auth_expire, .reset_credits, .plan, .last, .rate_5h, .rate_week, .account }; + +pub fn defaultTargetWidth(id: ColumnId) usize { + return switch (id) { + .number => 2, + .account => live_account_ident_width, + .plan => "PLAN".len, + .credits => 2, + .reset_credits => 2, + .rate_5h => @max(4, "5H".len), + .rate_week => "WEEKLY".len, + .last => 4, + .auth_expire => 10, + }; +} -/// Shared responsive column allocator for every table view. -/// -/// Input widths are content widths. A zero input width disables a column -/// (used by the live view to hide CREDITS and RESET CREDITS). The first pass -/// admits columns by priority only when their target width and separator fit; -/// ACCOUNT may use a partial width when nothing else fits. The second pass -/// grows visible columns toward their content width, keeping ACCOUNT last so -/// long labels cannot starve values such as `unlimited` or error names. -/// Columns that receive zero width must be skipped by the caller. -pub fn boundColumnWidths(widths: [column_count]usize, prefix_len: usize, max_cols: ?usize) [column_count]usize { - const cols = max_cols orelse return widths; - var bounded = [_]usize{0} ** column_count; - if (cols <= prefix_len) return bounded; - - var remaining = cols - prefix_len; +pub fn columnSpec(id: ColumnId, content_width: usize) ColumnSpec { + const target = defaultTargetWidth(id); + return .{ + .id = id, + .content_width = content_width, + .target_width = target, + .min_width = if (id == .account) account_min_width else target, + .allow_partial = id == .account, + }; +} + +pub fn allocateColumns(layout: ColumnLayout, max_cols: ?usize, widths: []usize) void { + @memset(widths, 0); + if (widths.len != layout.columns.len) return; + + if (max_cols == null) { + for (layout.columns, 0..) |column, index| widths[index] = column.content_width; + return; + } + + const cols = max_cols.?; + if (cols <= layout.prefix_width) return; + + var remaining = cols - layout.prefix_width; var visible_count: usize = 0; - // First pass: reserve targets by priority. Separators are charged only - // when a column actually becomes visible. - for (first_pass_order) |index| { - const target = @min(widths[index], column_target_widths[index]); + for (layout.first_order) |id| { + const index = columnIndex(layout.columns, id) orelse continue; + const column = layout.columns[index]; + if (column.content_width == 0) continue; + + const target = @min(column.content_width, column.target_width); if (target == 0) continue; - if (index == 0) { - const width = @min(target, remaining); - if (width < account_min_width) continue; - bounded[index] = width; - remaining -= width; + const separator = if (visible_count == 0) 0 else layout.separator_width; + + if (column.allow_partial) { + if (remaining < column.min_width + separator) break; + const width = @min(target, remaining - separator); + if (width < column.min_width) break; + widths[index] = width; + remaining -= width + separator; visible_count += 1; continue; } - const separator_width: usize = if (visible_count == 0) 0 else 2; - if (remaining < target + separator_width) continue; - bounded[index] = target; - remaining -= target + separator_width; + if (remaining < target + separator) break; + widths[index] = target; + remaining -= target + separator; visible_count += 1; } - // Second pass: grow the variable-length credit cell first so values like - // `unlimited` do not collapse to `u.`, then the usage windows, and keep - // account identity last so long labels cannot starve other values. - for (grow_pass_order) |index| { - if (bounded[index] == 0) continue; - growBoundedWidth(&remaining, &bounded[index], widths[index]); + for (layout.grow_order) |id| { + const index = columnIndex(layout.columns, id) orelse continue; + if (widths[index] == 0) continue; + growBoundedWidth(&remaining, &widths[index], layout.columns[index].content_width); } - - return bounded; } -pub fn switchWidthsToColumns(widths: SwitchWidths) [column_count]usize { - return .{ - widths.email, - widths.plan, - 0, - 0, - widths.rate_5h, - widths.rate_week, - widths.last, - }; +fn columnIndex(columns: []const ColumnSpec, id: ColumnId) ?usize { + for (columns, 0..) |column, index| { + if (column.id == id) return index; + } + return null; } fn growBoundedWidth(remaining: *usize, current: *usize, target: usize) void { diff --git a/src/tui/table.zig b/src/tui/table.zig index 684e5b61..3f536bbe 100644 --- a/src/tui/table.zig +++ b/src/tui/table.zig @@ -183,7 +183,18 @@ pub fn writeAccountsTableWithUsageOverridesForCols( } } - const widths = table_layout.boundColumnWidths(content_widths, prefix_len, term_cols); + var columns: [table_layout.plain_columns.len]table_layout.ColumnSpec = undefined; + for (table_layout.plain_columns, 0..) |id, index| { + columns[index] = table_layout.columnSpec(id, content_widths[index]); + } + var widths = [_]usize{0} ** 7; + table_layout.allocateColumns(.{ + .columns = &columns, + .first_order = &table_layout.plain_first_order, + .grow_order = &table_layout.plain_grow_order, + .prefix_width = prefix_len, + .separator_width = sep_len, + }, term_cols, &widths); var visible: [7]bool = undefined; var header_cells: [7][]const u8 = undefined; for (widths, 0..) |width, index| { diff --git a/tests/cli_picker_test.zig b/tests/cli_picker_test.zig index 34ed8380..782fbcd5 100644 --- a/tests/cli_picker_test.zig +++ b/tests/cli_picker_test.zig @@ -1774,3 +1774,46 @@ test "live list distinguishes unavailable reset details from an empty server lis try std.testing.expect(std.mem.indexOf(u8, body.written(), "#*") != null); try std.testing.expect(std.mem.indexOf(u8, body.written(), "CREDIT 1 EXP") == null); } + +test "live list details keep the responsive column order and complete percentages" { + const gpa = std.testing.allocator; + var reg = makeTestRegistry(); + defer reg.deinit(gpa); + try appendTestAccount(gpa, ®, "user::account", "very-long-account-name@example.com", "", .pro); + reg.accounts.items[0].access_token_expires_at = 4102444800; + reg.accounts.items[0].last_usage = .{ + .primary = .{ .used_percent = 0, .window_minutes = 300, .resets_at = 4102444800 }, + .secondary = .{ .used_percent = 42, .window_minutes = 10080, .resets_at = 4103049600 }, + .credits = null, + .plan_type = .pro, + .reset_credits = 3, + }; + 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); + } + if (width == 140) { + var last_index: usize = 0; + for ([_][]const u8{ "#*", "ACCOUNT", "PLAN", "5H", "WEEKLY", "LAST", "AUTH EXPIRE", "RESET CREDITS" }) |token| { + const index = std.mem.indexOf(u8, header, token) orelse return error.TestUnexpectedResult; + try std.testing.expect(index >= last_index); + last_index = index; + } + } + } +} From 06172c07fd487784ed6ee4f98ccd12f3fc1aad9b Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 22 Oct 2026 17:46:42 +0800 Subject: [PATCH 10/19] feat: compact left-rail reset credits panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render the live panel as a rail: a top line with 'Remaining N', the ⏎ select hint and the esc close hint, compact card rows (title plus MM-DD HH:MM and a relative countdown, or - when no expiry), the armed hint on the selected row, and a bare bottom rail. esc now closes the panel instead of quitting the live view; collapsing with c or esc also clears the pending confirm state. Unit test covers the rendered rail. --- docs/commands/list.md | 7 ++++ src/cli/live_view.zig | 11 +++++- src/cli/reset_credits_panel.zig | 70 +++++++++++---------------------- tests/cli_picker_test.zig | 19 +++++++++ 4 files changed, 59 insertions(+), 48 deletions(-) diff --git a/docs/commands/list.md b/docs/commands/list.md index 9a0dbdc9..86bad5c9 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -38,6 +38,13 @@ codex-auth list --json the list as exhaustive. After a refresh failure, 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 block under the cursor row. +- The block header reads `Remaining N · ⏎ select · esc close`. +- `⏎` selects the highlighted credit; pressing `⏎` again redeems it (two-step confirm). `esc` closes the block without redeeming. +- 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 diff --git a/src/cli/live_view.zig b/src/cli/live_view.zig index a309153e..b68b219a 100644 --- a/src/cli/live_view.zig +++ b/src/cli/live_view.zig @@ -428,12 +428,21 @@ pub fn viewAccountsWithLiveUpdates( continue; } switch (key) { - .quit => return, + .quit => { + if (credits_expanded) { + credits_expanded = false; + confirm_pending = false; + needs_render = true; + continue; + } + return; + }, .redraw => needs_render = true, .byte => |ch| { if (isQuitKey(ch)) return; if (ch == 'c') { credits_expanded = !credits_expanded; + if (!credits_expanded) confirm_pending = false; needs_render = true; continue; } diff --git a/src/cli/reset_credits_panel.zig b/src/cli/reset_credits_panel.zig index fde0bbd8..fd90c825 100644 --- a/src/cli/reset_credits_panel.zig +++ b/src/cli/reset_credits_panel.zig @@ -45,10 +45,10 @@ pub fn collectAvailable( return cards.toOwnedSlice(allocator); } -/// Renders the read-only RESET CREDITS detail block. Lines are bounded to -/// `max_cols` and '\n'-terminated. `card_cursor` marks the selected credit -/// (cursor ▸ + a status/granted detail line); `confirm_pending` asks for the -/// second ⏎; `status_line` surfaces the result of the last action. +/// 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, @@ -60,14 +60,17 @@ pub fn render( status_line: ?[]const u8, ) !usize { var lines: usize = 0; + const budget = if (max_cols > 2) max_cols - 2 else max_cols; var header_buf: [128]u8 = undefined; - const header = std.fmt.bufPrint(&header_buf, "RESET CREDITS · {d} available", .{cards.len}) catch "RESET CREDITS"; - try writeRule(writer, "┌ ", header, max_cols, true); + const header = std.fmt.bufPrint(&header_buf, "Remaining {d} · ⏎ select · esc close", .{cards.len}) catch "Remaining"; + try writer.writeAll("┌ "); + try writeBounded(writer, header, budget); try writer.writeByte('\n'); lines += 1; if (cards.len == 0) { - try writeBounded(writer, "│ no available reset credits · run `codex-auth list --api` to refresh", max_cols); + 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 { @@ -75,47 +78,34 @@ pub fn render( const selected = index == card_cursor; var line: std.Io.Writer.Allocating = .init(allocator); defer line.deinit(); - if (selected) try line.writer.writeAll("▸ "); + try line.writer.writeAll(if (selected) "▸ " else " "); try line.writer.writeAll(card.title orelse "(untitled)"); - if (card.expires_at) |expiry| { - if (parseIsoEpoch(expiry)) |epoch| { - const local = try rows.formatTimestampAlloc(allocator, epoch); - defer allocator.free(local); - const rel = try relativeIn(allocator, epoch, now); - defer allocator.free(rel); - try line.writer.print(" · expires {s} ({s})", .{ local, rel }); - } else { - try line.writer.print(" · expires {s}", .{expiry}); - } + 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(" · no expiry"); + try line.writer.writeAll(" · -"); } - if (selected and confirm_pending) try line.writer.writeAll(" · ⏎ again to use it"); + if (selected and confirm_pending) try line.writer.writeAll(" · ⏎ again to use"); try writer.writeAll("│ "); - try writeBounded(writer, line.written(), max_cols); + try writeBounded(writer, line.written(), budget); try writer.writeByte('\n'); lines += 1; - if (selected) { - var detail: std.Io.Writer.Allocating = .init(allocator); - defer detail.deinit(); - try detail.writer.writeAll(" status available"); - if (selected and confirm_pending) try detail.writer.writeAll(" · redeeming this resets the 5h/weekly windows"); - try writer.writeAll("│ "); - try writeBounded(writer, detail.written(), max_cols); - try writer.writeByte('\n'); - lines += 1; - } } } if (status_line) |message| { try writer.writeAll("│ "); - try writeBounded(writer, message, max_cols); + try writeBounded(writer, message, budget); try writer.writeByte('\n'); lines += 1; } - try writeRule(writer, "└ ", "⏎ use · c collapse", max_cols, false); + try writer.writeAll("└\n"); lines += 1; return lines; } @@ -193,20 +183,6 @@ fn relativeIn(allocator: std.mem.Allocator, target: i64, now: i64) ![]u8 { return std.fmt.allocPrint(allocator, "in {d}d", .{@divTrunc(delta, 86400)}); } -fn writeRule(writer: *std.Io.Writer, open: []const u8, label: []const u8, max_cols: usize, top: bool) !void { - try writer.writeAll(open); - try writer.writeAll(label); - try writer.writeAll(" "); - var used: usize = open.len + label.len + 1; - const corner: usize = 1; - if (used + corner > max_cols) return; - while (used + corner < max_cols) { - try writer.writeAll("─"); - used += 1; - } - try writer.writeAll(if (top) "┐" else "┘"); -} - fn writeBounded(writer: *std.Io.Writer, text: []const u8, max_cols: usize) !void { if (text.len <= max_cols) { try writer.writeAll(text); diff --git a/tests/cli_picker_test.zig b/tests/cli_picker_test.zig index 782fbcd5..ec6b1898 100644 --- a/tests/cli_picker_test.zig +++ b/tests/cli_picker_test.zig @@ -1704,6 +1704,25 @@ 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"); + const text = out.written(); + try std.testing.expect(std.mem.indexOf(u8, text, "┌ Remaining 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 "live list renders a dash when available reset credits have no expiry" { const gpa = std.testing.allocator; var reg = makeTestRegistry(); From 5bbc98c53194d798a40f20444873a32153ad12b0 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 22 Oct 2026 18:21:49 +0800 Subject: [PATCH 11/19] feat: nest the reset rail, show Available N, and refresh after use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Indent the rail to the account column so it nests under its row (ANSI-aware header scan for the column offset) - Header now reads 'Available N · ⏎ select · esc close' - After a successful redeem, refresh that account's usage immediately so the 5H/WEEKLY cells and the remaining card list reflect the reset; the success message is the compact '✓ reset credit used' - Unit test covers the indented rail and the new header --- docs/commands/list.md | 3 +- src/cli/live_view.zig | 102 +++++++++++++++++++++++++------- src/cli/reset_credits_panel.zig | 17 +++++- tests/cli_picker_test.zig | 6 +- 4 files changed, 102 insertions(+), 26 deletions(-) diff --git a/docs/commands/list.md b/docs/commands/list.md index 86bad5c9..e6cc2fe3 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -41,8 +41,9 @@ codex-auth list --json ## Live Reset Credits - With `--live`, `c` expands or collapses the reset-credit block under the cursor row. -- The block header reads `Remaining N · ⏎ select · esc close`. +- The block header reads `Available N · ⏎ select · esc close`. - `⏎` selects the highlighted credit; pressing `⏎` again redeems it (two-step confirm). `esc` closes the block without redeeming. +- 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. diff --git a/src/cli/live_view.zig b/src/cli/live_view.zig index b68b219a..81f3ca30 100644 --- a/src/cli/live_view.zig +++ b/src/cli/live_view.zig @@ -12,6 +12,59 @@ 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 + 2; + 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; +} fn setPanelStatus(allocator: std.mem.Allocator, slot: *?[]u8, text: []const u8) !void { if (slot.*) |old| allocator.free(old); @@ -339,6 +392,7 @@ pub fn viewAccountsWithLiveUpdates( card_cursor, confirm_pending, panel_status, + panelIndentForBody(body_text), ); spliced_panel = try reset_credits_panel.spliceAfterDataLine( allocator, @@ -472,22 +526,22 @@ pub fn viewAccountsWithLiveUpdates( redeem_credit_id = allocator.dupe(u8, selected_credit_id.?) catch null; } if (redeem_key) |key| { - var result: reset_credits_api.ConsumeResult = blk: { - const codex_home = registry.resolveCodexHome(allocator) catch |err| { - break :blk reset_credits_api.ConsumeResult{ .outcome = .request_error, .error_name = try allocator.dupe(u8, @errorName(err)) }; - }; - defer allocator.free(codex_home); - break :blk 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)) }; + const codex_home = registry.resolveCodexHome(allocator) catch |err| { + try setPanelStatus(allocator, &panel_status, @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, .no_credit => { + .reset, .already_redeemed => { var already_consumed = false; for (consumed_ids.items) |id| { if (std.mem.eql(u8, id, selected_credit_id.?)) { @@ -503,13 +557,21 @@ pub fn viewAccountsWithLiveUpdates( redeem_key = null; if (redeem_credit_id) |c| allocator.free(c); redeem_credit_id = null; - const message = switch (result.outcome) { - .reset => "✓ reset credit used · windows reset on next refresh", - .already_redeemed => "✓ reset credit was already used · windows reset on next refresh", - .no_credit => "✗ reset credit is no longer available · refresh to see current resets", - else => unreachable, - }; - try setPanelStatus(allocator, &panel_status, message); + try setPanelStatus(allocator, &panel_status, "✓ 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 setPanelStatus(allocator, &panel_status, "✗ reset credit is no longer available · refresh to see current resets"); }, .nothing_to_reset => { try setPanelStatus(allocator, &panel_status, "reset not needed — usage does not need a reset right now"); diff --git a/src/cli/reset_credits_panel.zig b/src/cli/reset_credits_panel.zig index fd90c825..2622907e 100644 --- a/src/cli/reset_credits_panel.zig +++ b/src/cli/reset_credits_panel.zig @@ -58,17 +58,20 @@ pub fn render( card_cursor: usize, confirm_pending: bool, status_line: ?[]const u8, + indent: usize, ) !usize { var lines: usize = 0; - const budget = if (max_cols > 2) max_cols - 2 else max_cols; + 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, "Remaining {d} · ⏎ select · esc close", .{cards.len}) catch "Remaining"; + 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'); @@ -91,6 +94,7 @@ pub fn render( 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'); @@ -99,17 +103,26 @@ pub fn render( } 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. pub fn spliceAfterDataLine(allocator: std.mem.Allocator, body: []const u8, data_line: usize, panel: []const u8) ![]u8 { diff --git a/tests/cli_picker_test.zig b/tests/cli_picker_test.zig index ec6b1898..c210e0d5 100644 --- a/tests/cli_picker_test.zig +++ b/tests/cli_picker_test.zig @@ -1713,10 +1713,10 @@ test "reset credits panel renders the remaining rail and compact rows" { }; 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"); + _ = 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, "┌ Remaining 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, " ┌ 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); From 6fd5173e22dfc96f9bbd35954c7ae3643a838558 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 22 Oct 2026 18:24:20 +0800 Subject: [PATCH 12/19] fix: align the reset rail with the row separator Start the rail at the column of the first '|' separator (the one next to the display number) instead of the ACCOUNT column, so the rail's left edge continues that separator line. --- src/cli/live_view.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/live_view.zig b/src/cli/live_view.zig index 81f3ca30..c59007d0 100644 --- a/src/cli/live_view.zig +++ b/src/cli/live_view.zig @@ -32,7 +32,7 @@ fn panelIndentForBody(body: []const u8) usize { } continue; } - if (header[index] == '|') return column + 2; + if (header[index] == '|') return column; column += 1; index += 1; } From 96c828b0f47f36cc94abe3d502116de3728339c9 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Sun, 25 Oct 2026 18:10:33 +0800 Subject: [PATCH 13/19] fix: align live LAST header and reset rail with the shared width policy - Render LAST ACTIVITY in list --live when the column is wide enough, falling back to LAST like the plain list. - Bound reset-credit rail text by display columns instead of bytes. - Add tests for shared column order and multibyte rail truncation. --- docs/commands/list.md | 2 +- docs/dynamic-columns.md | 8 +++++++- src/cli/list_details.zig | 4 ++-- src/cli/reset_credits_panel.zig | 18 ++++++++++++++++-- src/cli/table_layout.zig | 11 +++++++++++ tests/cli_picker_test.zig | 2 +- 6 files changed, 38 insertions(+), 7 deletions(-) diff --git a/docs/commands/list.md b/docs/commands/list.md index 894d66e0..2c84e184 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -65,7 +65,7 @@ When local-only refresh is active, only the active account can be updated from l 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/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/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/reset_credits_panel.zig b/src/cli/reset_credits_panel.zig index 2622907e..7acd7685 100644 --- a/src/cli/reset_credits_panel.zig +++ b/src/cli/reset_credits_panel.zig @@ -1,6 +1,7 @@ const std = @import("std"); const registry = @import("../registry/root.zig"); const rows = @import("rows.zig"); +const text_width = @import("../tui/text_width.zig"); const available_status = "available"; @@ -197,7 +198,7 @@ fn relativeIn(allocator: std.mem.Allocator, target: i64, now: i64) ![]u8 { } fn writeBounded(writer: *std.Io.Writer, text: []const u8, max_cols: usize) !void { - if (text.len <= max_cols) { + if (text_width.displayWidth(text) <= max_cols) { try writer.writeAll(text); return; } @@ -205,7 +206,7 @@ fn writeBounded(writer: *std.Io.Writer, text: []const u8, max_cols: usize) !void try writer.writeAll("..."); return; } - try writer.writeAll(text[0 .. max_cols - 3]); + try writer.writeAll(text[0..text_width.prefixByteLength(text, max_cols - 3)]); try writer.writeAll("..."); } @@ -215,3 +216,16 @@ test "parseIsoEpoch handles backend strings" { try std.testing.expectEqual(@as(i64, 1793491200), epoch.?); try std.testing.expect(parseIsoEpoch("nonsense") == null); } + +test "render bounds multibyte lines by display width" { + const gpa = std.testing.allocator; + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + _ = try 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); + } +} diff --git a/src/cli/table_layout.zig b/src/cli/table_layout.zig index 173bf440..98c4dc7f 100644 --- a/src/cli/table_layout.zig +++ b/src/cli/table_layout.zig @@ -348,3 +348,14 @@ pub fn writeAccountTruncatedPadded(out: *std.Io.Writer, value: []const u8, width fn writeRepeat(out: *std.Io.Writer, ch: u8, count: usize) !void { try out.splatByteAll(ch, count); } + +test "plain and live views keep shared columns in the same relative order" { + var previous: ?usize = null; + for (list_detail_columns) |id| { + if (id == .number) continue; + const position = std.mem.indexOfScalar(ColumnId, &plain_columns, id) orelse + return error.TestUnexpectedResult; + if (previous) |prev| try std.testing.expect(position > prev); + previous = position; + } +} diff --git a/tests/cli_picker_test.zig b/tests/cli_picker_test.zig index 0859968b..c3bb7ed2 100644 --- a/tests/cli_picker_test.zig +++ b/tests/cli_picker_test.zig @@ -1871,7 +1871,7 @@ test "live list details keep the responsive column order and complete percentage 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; From 8c7f8717137c9aad5b48c38a44453ba4a5098247 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Sun, 25 Oct 2026 19:52:34 +0800 Subject: [PATCH 14/19] fix: pin the reset credits rail to one account and one credit The live rail cursor mixed two index spaces: it indexed rows.items while the renderer skips group header rows, so the rail could show one account while sitting under another, and the consume target followed the wrong account. renderListBody now shares one predicate with the exported data-row mapping, and the rail cursor is a rendered data-row index only. Redemption is irreversible, so the armed confirmation is bound to the exact (account_key, credit_id) pair instead of a flag: any cursor move, rail close, selection loss, or registry refresh drops it, and approve() releases the redeem exactly once. q now closes the open rail like esc, the rail status line is owned by the selection and cleared when the row changes, and the panel reuses time.timestamp.parseIso8601 instead of a weaker local parser. spliceAfterDataLine no longer emits a phantom trailing row (it inflated the viewport row count) and no longer leaks its buffer on every rendered frame. --- docs/commands/list.md | 9 +- src/cli/live_view.zig | 200 +++++++++++++++---------- src/cli/render.zig | 35 ++++- src/cli/reset_credits_panel.zig | 249 ++++++++++++++++++++++++-------- src/cli/tui.zig | 4 +- src/root.zig | 1 + tests/cli_picker_test.zig | 248 ++++++++++++++++++++++++++++++- 7 files changed, 592 insertions(+), 154 deletions(-) diff --git a/docs/commands/list.md b/docs/commands/list.md index 2c84e184..158ddbf9 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -43,9 +43,12 @@ codex-auth list --json ## Live Reset Credits -- With `--live`, `c` expands or collapses the reset-credit block under the cursor row. -- The block header reads `Available N · ⏎ select · esc close`. -- `⏎` selects the highlighted credit; pressing `⏎` again redeems it (two-step confirm). `esc` closes the block without redeeming. +- 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. +- 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 `-`. diff --git a/src/cli/live_view.zig b/src/cli/live_view.zig index c59007d0..6f1fdd5a 100644 --- a/src/cli/live_view.zig +++ b/src/cli/live_view.zig @@ -66,9 +66,20 @@ fn alreadyConsumed(consumed_ids: []const []u8, credit_id: []const u8) bool { return false; } -fn setPanelStatus(allocator: std.mem.Allocator, slot: *?[]u8, text: []const u8) !void { - if (slot.*) |old| allocator.free(old); - slot.* = try allocator.dupe(u8, text); +/// 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; @@ -322,22 +333,18 @@ pub fn viewAccountsWithLiveUpdates( defer body.deinit(); var credits_panel: std.Io.Writer.Allocating = .init(allocator); defer credits_panel.deinit(); - var credits_cursor: usize = 0; - var credits_expanded: bool = false; - var card_cursor: usize = 0; - var confirm_pending: bool = false; + var credit_selection: reset_credits_panel.Selection = .{}; + var data_row_count: usize = 0; var pending_consume: bool = false; - var panel_status: ?[]u8 = null; var selected_credit_id: ?[]u8 = null; var selected_account_key: ?[]u8 = null; var redeem_key: ?[]u8 = null; var redeem_credit_id: ?[]u8 = null; - var available_count_cache: usize = 0; 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 (panel_status) |status| allocator.free(status); if (selected_credit_id) |id| allocator.free(id); if (selected_account_key) |key| allocator.free(key); if (redeem_key) |key| allocator.free(key); @@ -350,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(); @@ -368,49 +376,60 @@ pub fn viewAccountsWithLiveUpdates( 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 data_rows = std.mem.count(u8, body_text, "\n") -| 1; - if (credits_cursor >= data_rows) credits_cursor = if (data_rows == 0) 0 else data_rows - 1; - if (credits_expanded and data_rows > 0) { - const cursor_row = rows.items[credits_cursor]; - if (cursor_row.account_index) |account_idx| { - if (account_idx < current_display.reg.accounts.items.len) { - const account = ¤t_display.reg.accounts.items[account_idx]; - available_cards = try reset_credits_panel.collectAvailable( - allocator, - account, - consumed_ids.items, - ); - available_count_cache = available_cards.len; - if (card_cursor >= available_cards.len) card_cursor = if (available_cards.len == 0) 0 else available_cards.len - 1; - credits_panel.clearRetainingCapacity(); - _ = try reset_credits_panel.render( - allocator, - &credits_panel.writer, - available_cards, - tui.terminalCols(), - now_second, - card_cursor, - confirm_pending, - panel_status, - panelIndentForBody(body_text), - ); - spliced_panel = try reset_credits_panel.spliceAfterDataLine( - allocator, - body_text, - credits_cursor, - credits_panel.written(), - ); - body_text = spliced_panel.?; - if (selected_credit_id) |id| allocator.free(id); - selected_credit_id = if (available_cards.len > 0) - try allocator.dupe(u8, available_cards[card_cursor].id) - else - null; - if (selected_account_key) |key| allocator.free(key); - selected_account_key = try allocator.dupe(u8, account.account_key); + 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); + credits_panel.clearRetainingCapacity(); + _ = try reset_credits_panel.render( + allocator, + &credits_panel.writer, + available_cards, + tui.terminalCols(), + now_second, + credit_selection.card_cursor, + credit_selection.isPending(), + 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 (available_cards.len > 0) + try allocator.dupe(u8, available_cards[credit_selection.card_cursor].id) + else + null; + if (selected_account_key) |key| allocator.free(key); + selected_account_key = try allocator.dupe(u8, account.account_key); + } 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); @@ -449,27 +468,39 @@ pub fn viewAccountsWithLiveUpdates( const wheel_rows = live_tui.mouseWheelRows(max_rows); for (key_buf[0..key_count]) |key| { - if (credits_expanded) { + if (credit_selection.expanded) { switch (key) { .move_up, .keyboard_up => { - if (card_cursor > 0) card_cursor -= 1; + credit_selection.moveCard(allocator, .up); needs_render = true; continue; }, .move_down, .keyboard_down => { - card_cursor += 1; + credit_selection.moveCard(allocator, .down); needs_render = true; continue; }, .enter => { - if (available_count_cache > 0 and selected_credit_id != null) { - if (!confirm_pending) { - confirm_pending = true; - needs_render = true; - } else { - confirm_pending = false; - pending_consume = true; - needs_render = true; + 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; @@ -483,9 +514,8 @@ pub fn viewAccountsWithLiveUpdates( } switch (key) { .quit => { - if (credits_expanded) { - credits_expanded = false; - confirm_pending = false; + if (credit_selection.expanded) { + credit_selection.close(allocator); needs_render = true; continue; } @@ -493,20 +523,30 @@ pub fn viewAccountsWithLiveUpdates( }, .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') { - credits_expanded = !credits_expanded; - if (!credits_expanded) confirm_pending = false; + credit_selection.toggleExpanded(allocator); needs_render = true; continue; } if (ch == 'j') { - if (credits_cursor + 1 < rendered_row_count) credits_cursor += 1; + credit_selection.moveRow(allocator, .down, data_row_count); needs_render = true; continue; } if (ch == 'k') { - if (credits_cursor > 0) credits_cursor -= 1; + credit_selection.moveRow(allocator, .up, data_row_count); needs_render = true; continue; } @@ -516,7 +556,7 @@ pub fn viewAccountsWithLiveUpdates( } if (pending_consume) { pending_consume = false; - if (selected_credit_id != null and selected_account_key != null) { + 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.?)) { @@ -527,7 +567,7 @@ pub fn viewAccountsWithLiveUpdates( } if (redeem_key) |key| { const codex_home = registry.resolveCodexHome(allocator) catch |err| { - try setPanelStatus(allocator, &panel_status, @errorName(err)); + try credit_selection.setStatus(allocator, @errorName(err)); needs_render = true; continue; }; @@ -557,7 +597,7 @@ pub fn viewAccountsWithLiveUpdates( redeem_key = null; if (redeem_credit_id) |c| allocator.free(c); redeem_credit_id = null; - try setPanelStatus(allocator, &panel_status, "✓ reset credit used"); + 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); @@ -571,35 +611,35 @@ pub fn viewAccountsWithLiveUpdates( redeem_key = null; if (redeem_credit_id) |c| allocator.free(c); redeem_credit_id = null; - try setPanelStatus(allocator, &panel_status, "✗ reset credit is no longer available · refresh to see current resets"); + try credit_selection.setStatus(allocator, "✗ reset credit is no longer available · refresh to see current resets"); }, .nothing_to_reset => { - try setPanelStatus(allocator, &panel_status, "reset not needed — usage does not need a reset right now"); + 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 setPanelStatus(allocator, &panel_status, text); + try credit_selection.setStatus(allocator, text); } else { - try setPanelStatus(allocator, &panel_status, "✗ use failed (unexpected response)"); + 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 setPanelStatus(allocator, &panel_status, text); + try credit_selection.setStatus(allocator, text); } else { - try setPanelStatus(allocator, &panel_status, "use failed"); + try credit_selection.setStatus(allocator, "use failed"); } }, .request_error => { - try setPanelStatus(allocator, &panel_status, result.error_name orelse "use failed"); + try credit_selection.setStatus(allocator, result.error_name orelse "use failed"); }, } } else { - try setPanelStatus(allocator, &panel_status, "✗ use failed (idempotency key unavailable)"); + try credit_selection.setStatus(allocator, "✗ use failed (idempotency key unavailable)"); } needs_render = true; } diff --git a/src/cli/render.zig b/src/cli/render.zig index 0778dbe2..4b235fee 100644 --- a/src/cli/render.zig +++ b/src/cli/render.zig @@ -412,6 +412,36 @@ fn writeSwitchRow( displayed_counter.* += 1; } +/// Single source of truth for which rows the live list body renders. The rail +/// cursor indexes rendered data rows, so row mapping and rendering must share +/// this predicate or they drift into two truths again. +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 +459,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 index 7acd7685..440f917d 100644 --- a/src/cli/reset_credits_panel.zig +++ b/src/cli/reset_credits_panel.zig @@ -1,6 +1,7 @@ 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"; @@ -12,6 +13,180 @@ pub const AvailableCard = struct { 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); + } + + pub fn isPending(self: *Selection) bool { + return self.armed != null; + } + + 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( @@ -39,7 +214,7 @@ pub fn collectAvailable( .id = credit.id, .title = credit.title, .expires_at = credit.expires_at, - .epoch = parseIsoEpoch(credit.expires_at orelse ""), + .epoch = timestamp.parseIso8601(credit.expires_at orelse ""), }); } std.mem.sort(AvailableCard, cards.items, {}, cardLessThan); @@ -126,20 +301,26 @@ fn writeIndent(writer: *std.Io.Writer, indent: usize) !void { /// 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) { - try out.writer.writeAll(line); - try out.writer.writeByte('\n'); + 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'); } } - return allocator.dupe(u8, out.written()); + const copy = try allocator.dupe(u8, out.written()); + out.deinit(); + return copy; } fn cardLessThan(_: void, a: AvailableCard, b: AvailableCard) bool { @@ -148,46 +329,6 @@ fn cardLessThan(_: void, a: AvailableCard, b: AvailableCard) bool { return a_epoch < b_epoch; } -/// Minimal ISO-8601 parser for the backend shape `YYYY-MM-DDTHH:MM:SS` with an -/// optional trailing zone (`Z` or `±HH:MM`). Returns epoch seconds, or null. -fn parseIsoEpoch(value: []const u8) ?i64 { - if (value.len < 19 or value[4] != '-' or value[7] != '-' or value[10] != 'T' or - value[13] != ':' or value[16] != ':') return null; - const year = std.fmt.parseInt(i64, value[0..4], 10) catch return null; - const month = std.fmt.parseInt(i64, value[5..7], 10) catch return null; - const day = std.fmt.parseInt(i64, value[8..10], 10) catch return null; - const hour = std.fmt.parseInt(i64, value[11..13], 10) catch return null; - const minute = std.fmt.parseInt(i64, value[14..16], 10) catch return null; - const second = std.fmt.parseInt(i64, value[17..19], 10) catch return null; - if (month < 1 or month > 12 or day < 1 or day > 31) return null; - - var epoch = daysFromCivil(year, month, day) * 86400 + hour * 3600 + minute * 60 + second; - if (value.len > 19) { - const zone_marker = value[19]; - if (zone_marker == '+' or zone_marker == '-') { - if (value.len >= 25 and value[22] == ':') { - const zone_hour = std.fmt.parseInt(i64, value[20..22], 10) catch 0; - const zone_minute = std.fmt.parseInt(i64, value[23..25], 10) catch 0; - const offset = zone_hour * 3600 + zone_minute * 60; - epoch += if (zone_marker == '+') -offset else offset; - } - } - } - return epoch; -} - -/// Howard Hinnant's days_from_civil algorithm. -fn daysFromCivil(year: i64, month: i64, day: i64) i64 { - var y = year; - if (month <= 2) y -= 1; - const era = @divFloor(y, 400); - const yoe = y - era * 400; - const mp = @mod(month + 9, 12); - const doy = @divFloor(153 * mp + 2, 5) + day - 1; - const doe = yoe * 365 + @divFloor(yoe, 4) - @divFloor(yoe, 100) + doy; - return era * 146097 + doe - 719468; -} - fn relativeIn(allocator: std.mem.Allocator, target: i64, now: i64) ![]u8 { const delta = target - now; if (delta <= 0) return std.fmt.allocPrint(allocator, "expired", .{}); @@ -209,23 +350,3 @@ fn writeBounded(writer: *std.Io.Writer, text: []const u8, max_cols: usize) !void try writer.writeAll(text[0..text_width.prefixByteLength(text, max_cols - 3)]); try writer.writeAll("..."); } - -test "parseIsoEpoch handles backend strings" { - const epoch = parseIsoEpoch("2026-11-01T00:00:00Z"); - try std.testing.expect(epoch != null); - try std.testing.expectEqual(@as(i64, 1793491200), epoch.?); - try std.testing.expect(parseIsoEpoch("nonsense") == null); -} - -test "render bounds multibyte lines by display width" { - const gpa = std.testing.allocator; - var out: std.Io.Writer.Allocating = .init(gpa); - defer out.deinit(); - _ = try 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); - } -} 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/root.zig b/src/root.zig index 98b487fa..6f027387 100644 --- a/src/root.zig +++ b/src/root.zig @@ -35,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/tests/cli_picker_test.zig b/tests/cli_picker_test.zig index c3bb7ed2..cbc2faaa 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), ); } @@ -1723,6 +1724,249 @@ test "reset credits panel renders the remaining rail and compact rows" { try std.testing.expect(std.mem.endsWith(u8, text, "└\n")); } +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.isPending()); + sel.moveCard(gpa, .down); + try std.testing.expect(!sel.isPending()); + 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.isPending()); + 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.isPending()); + 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.isPending()); + 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.isPending()); + 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.isPending()); + 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.isPending()); + 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.isPending()); + 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(); From a82b438ea6eab37bc2d4579f005f93b647cf50f3 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Sun, 25 Oct 2026 19:52:40 +0800 Subject: [PATCH 15/19] fix: json encode the reset credit consume body credit_id originates at the backend and round-trips through the stored registry, so interpolating it into body text could emit a malformed request or add fields. BuildConsumeBodyAlloc encodes both fields with std.json.Stringify. --- src/api/reset_credits.zig | 20 +++++++++++++++----- tests/api_usage_test.zig | 22 ++++++++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/api/reset_credits.zig b/src/api/reset_credits.zig index d3e74c42..7ce265fd 100644 --- a/src/api/reset_credits.zig +++ b/src/api/reset_credits.zig @@ -82,6 +82,20 @@ pub fn generateIdempotencyKeyAlloc(allocator: std.mem.Allocator) ![]u8 { 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, @@ -111,11 +125,7 @@ pub fn consumeCreditForAccount( 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 std.fmt.allocPrint( - allocator, - "{{\"redeem_request_id\":\"{s}\",\"credit_id\":\"{s}\"}}", - .{ redeem_request_id, credit_id }, - ); + const body = try buildConsumeBodyAlloc(allocator, redeem_request_id, credit_id); defer allocator.free(body); const http_result = http.runPostJsonCommand( diff --git a/tests/api_usage_test.zig b/tests/api_usage_test.zig index a63dae25..717d5f1d 100644 --- a/tests/api_usage_test.zig +++ b/tests/api_usage_test.zig @@ -294,6 +294,28 @@ test "consume outcome maps the backend code field" { ); } +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); From e7020ab16a1973f6d9b1ec7cb4bc0f858ef6654c Mon Sep 17 00:00:00 2001 From: Loongphy Date: Sun, 25 Oct 2026 19:52:40 +0800 Subject: [PATCH 16/19] test: keep the table layout module free of inline tests docs/tests.md requires production modules to hold no test blocks, so move the shared column order test to its own test root and export cli.table_layout as the module boundary it already is. --- src/cli/root.zig | 1 + src/cli/table_layout.zig | 11 ----------- tests/table_layout_test.zig | 12 ++++++++++++ 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/cli/root.zig b/src/cli/root.zig index 8b0a1e1f..740b629e 100644 --- a/src/cli/root.zig +++ b/src/cli/root.zig @@ -10,4 +10,5 @@ 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/table_layout.zig b/src/cli/table_layout.zig index 98c4dc7f..173bf440 100644 --- a/src/cli/table_layout.zig +++ b/src/cli/table_layout.zig @@ -348,14 +348,3 @@ pub fn writeAccountTruncatedPadded(out: *std.Io.Writer, value: []const u8, width fn writeRepeat(out: *std.Io.Writer, ch: u8, count: usize) !void { try out.splatByteAll(ch, count); } - -test "plain and live views keep shared columns in the same relative order" { - var previous: ?usize = null; - for (list_detail_columns) |id| { - if (id == .number) continue; - const position = std.mem.indexOfScalar(ColumnId, &plain_columns, id) orelse - return error.TestUnexpectedResult; - if (previous) |prev| try std.testing.expect(position > prev); - previous = position; - } -} 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; + } +} From c65150c0712afbb4fc67bdd486a4c8748d2eb78f Mon Sep 17 00:00:00 2001 From: Loongphy Date: Sun, 25 Oct 2026 20:02:44 +0800 Subject: [PATCH 17/19] test: move the last inline tests out of src docs/tests.md requires production modules to hold no test blocks; relocate the remaining live_tui and text_width tests into their behaviour test root with the descriptions unchanged, so the checklist command rg '^test "' src returns nothing. --- src/cli/live_tui.zig | 32 -------------------------------- src/tui/text_width.zig | 6 ------ tests/cli_picker_test.zig | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 38 deletions(-) diff --git a/src/cli/live_tui.zig b/src/cli/live_tui.zig index 5409704b..eb992ec5 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(); } 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/tests/cli_picker_test.zig b/tests/cli_picker_test.zig index cbc2faaa..1047824a 100644 --- a/tests/cli_picker_test.zig +++ b/tests/cli_picker_test.zig @@ -2148,3 +2148,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)..]); +} From b9fa8f79b38c8686b9d9114a82999570bf6b9dbb Mon Sep 17 00:00:00 2001 From: Loongphy Date: Sun, 25 Oct 2026 21:15:25 +0800 Subject: [PATCH 18/19] fix: keep the reset credits rail visible and its hint on the armed credit The list viewport only clamped its start, so with more accounts than one screen the rail could hang on a row outside the display: pressing c looked dead, and Enter Enter still redeemed a credit the user could not see along with its result line. listViewportFollowing now scrolls the rail row into view on the body line index that renderListBodyViewport actually slices. A burst of keys read in one batch could also swallow a queued redeem: the second Enter armed the consume, then a following j moved the rail and cleared the selection before the queued redeem ran, leaving no feedback at all. The batch now stops being consumed once a redeem is committed. The re-confirm hint was driven by 'armed is not null' while it is drawn on the cursor card, with no identity link. Selection now exposes isPendingFor(identity), and the frame derives the shown credit once and uses it for the hint, the owned copies, and the armed check. --- docs/commands/list.md | 1 + src/cli/live_tui.zig | 39 ++++++++++++++++++- src/cli/live_view.zig | 28 +++++++++++--- src/cli/reset_credits_panel.zig | 10 ++++- tests/cli_picker_test.zig | 66 ++++++++++++++++++++++++++++----- 5 files changed, 127 insertions(+), 17 deletions(-) diff --git a/docs/commands/list.md b/docs/commands/list.md index 158ddbf9..382fd121 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -45,6 +45,7 @@ codex-auth list --json - 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. diff --git a/src/cli/live_tui.zig b/src/cli/live_tui.zig index eb992ec5..f770cfc3 100644 --- a/src/cli/live_tui.zig +++ b/src/cli/live_tui.zig @@ -100,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 6f1fdd5a..1423f8bc 100644 --- a/src/cli/live_view.zig +++ b/src/cli/live_view.zig @@ -397,6 +397,16 @@ pub fn viewAccountsWithLiveUpdates( 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, @@ -405,7 +415,7 @@ pub fn viewAccountsWithLiveUpdates( tui.terminalCols(), now_second, credit_selection.card_cursor, - credit_selection.isPending(), + credit_selection.isPendingFor(shown_identity), credit_selection.status, panelIndentForBody(body_text), ); @@ -417,12 +427,15 @@ pub fn viewAccountsWithLiveUpdates( ); body_text = spliced_panel.?; if (selected_credit_id) |id| allocator.free(id); - selected_credit_id = if (available_cards.len > 0) - try allocator.dupe(u8, available_cards[credit_selection.card_cursor].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 = try allocator.dupe(u8, account.account_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); @@ -433,11 +446,12 @@ pub fn viewAccountsWithLiveUpdates( 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(); @@ -468,6 +482,10 @@ 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 => { diff --git a/src/cli/reset_credits_panel.zig b/src/cli/reset_credits_panel.zig index 440f917d..aaa8acde 100644 --- a/src/cli/reset_credits_panel.zig +++ b/src/cli/reset_credits_panel.zig @@ -67,8 +67,14 @@ pub const Selection = struct { self.clearStatus(allocator); } - pub fn isPending(self: *Selection) bool { - return self.armed != null; + /// 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 { diff --git a/tests/cli_picker_test.zig b/tests/cli_picker_test.zig index 1047824a..259998c1 100644 --- a/tests/cli_picker_test.zig +++ b/tests/cli_picker_test.zig @@ -1724,6 +1724,54 @@ test "reset credits panel renders the remaining rail and compact rows" { 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(); @@ -1779,9 +1827,9 @@ test "reset credits panel requires a second confirm on the same credit after mov defer sel.deinit(gpa); try std.testing.expectEqual(Step.armed, sel.confirm(gpa, card_zero)); - try std.testing.expect(sel.isPending()); + try std.testing.expect(sel.isPendingFor(card_zero)); sel.moveCard(gpa, .down); - try std.testing.expect(!sel.isPending()); + 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)); @@ -1799,7 +1847,7 @@ test "reset credits panel drops a pending confirm when the account row moves" { 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.isPending()); + 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); @@ -1807,7 +1855,7 @@ test "reset credits panel drops a pending confirm when the account row moves" { 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.isPending()); + try std.testing.expect(!sel.isPendingFor(card)); try std.testing.expectEqual(@as(usize, 0), sel.card_cursor); } @@ -1821,7 +1869,7 @@ test "reset credits panel cannot confirm a row without a consumable credit" { try std.testing.expectEqual(Step.armed, sel.confirm(gpa, card)); sel.clearSelection(gpa); try std.testing.expect(!sel.valid); - try std.testing.expect(!sel.isPending()); + 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)); @@ -1858,13 +1906,13 @@ test "reset credits panel keeps a confirm armed only while the credit stays high try std.testing.expectEqual(Step.armed, sel.confirm(gpa, first)); sel.updateCards(gpa, 2); - try std.testing.expect(sel.isPending()); + 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.isPending()); + try std.testing.expect(!sel.isPendingFor(second)); try std.testing.expectEqual(@as(usize, 0), sel.card_cursor); } @@ -1879,12 +1927,12 @@ test "reset credits panel close drops the pending confirm and the status line" { try std.testing.expectEqual(Step.armed, sel.confirm(gpa, card)); sel.close(gpa); try std.testing.expect(!sel.expanded); - try std.testing.expect(!sel.isPending()); + 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.isPending()); + try std.testing.expect(!sel.isPendingFor(card)); try std.testing.expect(sel.status == null); } From b771edbd96413e597293ec14d33edc087e5abe1c Mon Sep 17 00:00:00 2001 From: Loongphy Date: Sun, 25 Oct 2026 21:15:32 +0800 Subject: [PATCH 19/19] docs: name the two row index bases in the list renderer rowIndexForDisplayIndex/dataRowCount/liveViewportStartForDisplayIndex work on rows.items because the switch and remove screens render one line per row, group headers included. The live list body skips header rows and uses isRenderedLiveRow/liveDataRowCount/liveRowIndexForDataIndex. Both bases are correct for their own view; the names gave no hint, which is how a rail cursor nearly got fed to the wrong helper. Say so at each definition. --- src/cli/render.zig | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/cli/render.zig b/src/cli/render.zig index 4b235fee..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| { @@ -413,8 +428,9 @@ fn writeSwitchRow( } /// Single source of truth for which rows the live list body renders. The rail -/// cursor indexes rendered data rows, so row mapping and rendering must share -/// this predicate or they drift into two truths again. +/// 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;