From 6ba1019f6ca32e52523ece5e6b7a89a966b8844d Mon Sep 17 00:00:00 2001 From: Loongphy Date: Sun, 19 Apr 2026 20:25:23 +0800 Subject: [PATCH 01/11] feat: add live refresh to switch selector --- src/cli.zig | 705 ++++++++++++++++++++++++++++++++++++++++++++------- src/main.zig | 656 ++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 1239 insertions(+), 122 deletions(-) diff --git a/src/cli.zig b/src/cli.zig index 8cb57d3..7f931c6 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -25,6 +25,254 @@ const ansi = struct { const bold = "\x1b[1m"; }; +const tui_poll_error_mask: i16 = std.posix.POLL.ERR | std.posix.POLL.HUP | std.posix.POLL.NVAL; +const tui_escape_sequence_timeout_ms: i32 = 100; + +const TuiNavigation = enum { + up, + down, +}; + +const TuiEscapeClassification = union(enum) { + incomplete, + ignore, + navigation: TuiNavigation, +}; + +const TuiEscapeAction = enum { + quit, + ignore, + move_up, + move_down, +}; + +const TuiEscapeReadResult = struct { + action: TuiEscapeAction, + buffered_bytes_consumed: usize, +}; + +fn writeTuiEnterTo(out: *std.Io.Writer) !void { + try out.writeAll("\x1b[?1049h\x1b[?25l"); + try out.writeAll("\x1b[H\x1b[J"); +} + +fn writeTuiExitTo(out: *std.Io.Writer) !void { + try out.writeAll("\x1b[?25h\x1b[?1049l"); +} + +fn writeTuiResetFrameTo(out: *std.Io.Writer) !void { + try out.writeAll("\x1b[H\x1b[J"); +} + +const TuiSession = struct { + tty: fs.File, + saved_termios: std.posix.termios, + writer_buffer: [4096]u8 = undefined, + writer: fs.File.Writer = undefined, + + fn init() !@This() { + var tty = try fs.cwd().openFile("/dev/tty", .{}); + errdefer tty.close(); + + const saved_termios = try std.posix.tcgetattr(tty.handle); + var raw = saved_termios; + raw.lflag.ICANON = false; + raw.lflag.ECHO = false; + raw.cc[@intFromEnum(std.c.V.MIN)] = 1; + raw.cc[@intFromEnum(std.c.V.TIME)] = 0; + try std.posix.tcsetattr(tty.handle, .FLUSH, raw); + + var session = @This(){ + .tty = tty, + .saved_termios = saved_termios, + }; + session.writer = session.tty.writer(&session.writer_buffer); + try session.enter(); + return session; + } + + fn deinit(self: *@This()) void { + const writer = self.out(); + writeTuiExitTo(writer) catch {}; + writer.flush() catch {}; + std.posix.tcsetattr(self.tty.handle, .FLUSH, self.saved_termios) catch {}; + self.tty.close(); + self.* = undefined; + } + + fn out(self: *@This()) *std.Io.Writer { + return &self.writer.interface; + } + + fn read(self: *@This(), buffer: []u8) !usize { + return try self.tty.read(buffer); + } + + fn enter(self: *@This()) !void { + const writer = self.out(); + try writeTuiEnterTo(writer); + try writer.flush(); + } + + fn resetFrame(self: *@This()) !void { + try writeTuiResetFrameTo(self.out()); + } +}; + +fn classifyTuiEscapeSuffix(seq: []const u8) TuiEscapeClassification { + if (seq.len == 0) return .incomplete; + + return switch (seq[0]) { + '[' => blk: { + if (seq.len == 1) break :blk .incomplete; + const final = seq[seq.len - 1]; + if (final == 'A' or final == 'B') { + for (seq[1 .. seq.len - 1]) |ch| { + if (!std.ascii.isDigit(ch) and ch != ';') break :blk .ignore; + } + break :blk .{ .navigation = if (final == 'A') .up else .down }; + } + if (final >= '@' and final <= '~') break :blk .ignore; + break :blk .incomplete; + }, + 'O' => blk: { + if (seq.len == 1) break :blk .incomplete; + const code = seq[1]; + if (code == 'A' or code == 'B') { + break :blk .{ .navigation = if (code == 'A') .up else .down }; + } + break :blk .ignore; + }, + else => .ignore, + }; +} + +fn readTuiEscapeAction( + tty: fs.File, + buffered_tail: []const u8, + poll_error_mask: i16, + timeout_ms: i32, +) !TuiEscapeReadResult { + var seq: [8]u8 = undefined; + var seq_len: usize = 0; + var buffered_bytes_consumed: usize = 0; + + while (true) { + switch (classifyTuiEscapeSuffix(seq[0..seq_len])) { + .navigation => |direction| { + return .{ + .action = switch (direction) { + .up => .move_up, + .down => .move_down, + }, + .buffered_bytes_consumed = buffered_bytes_consumed, + }; + }, + .ignore => return .{ + .action = .ignore, + .buffered_bytes_consumed = buffered_bytes_consumed, + }, + .incomplete => {}, + } + + if (buffered_bytes_consumed < buffered_tail.len) { + if (seq_len == seq.len) { + return .{ + .action = .ignore, + .buffered_bytes_consumed = buffered_bytes_consumed, + }; + } + seq[seq_len] = buffered_tail[buffered_bytes_consumed]; + seq_len += 1; + buffered_bytes_consumed += 1; + continue; + } + + if (seq_len == seq.len) { + return .{ + .action = .ignore, + .buffered_bytes_consumed = buffered_bytes_consumed, + }; + } + + var fds = [_]std.posix.pollfd{.{ + .fd = tty.handle, + .events = std.posix.POLL.IN, + .revents = 0, + }}; + const ready = try std.posix.poll(&fds, timeout_ms); + if (ready == 0) { + return .{ + .action = if (seq_len == 0) .quit else .ignore, + .buffered_bytes_consumed = buffered_bytes_consumed, + }; + } + if ((fds[0].revents & poll_error_mask) != 0) { + return .{ + .action = .quit, + .buffered_bytes_consumed = buffered_bytes_consumed, + }; + } + + const read_n = try tty.read(seq[seq_len .. seq_len + 1]); + if (read_n == 0) { + return .{ + .action = if (seq_len == 0) .quit else .ignore, + .buffered_bytes_consumed = buffered_bytes_consumed, + }; + } + seq_len += read_n; + } +} + +test "Scenario: Given tty arrow escape suffixes when classifying them then both CSI and SS3 arrows are recognized" { + switch (classifyTuiEscapeSuffix("[A")) { + .navigation => |direction| try std.testing.expectEqual(TuiNavigation.up, direction), + else => return error.TestUnexpectedResult, + } + switch (classifyTuiEscapeSuffix("[1;2B")) { + .navigation => |direction| try std.testing.expectEqual(TuiNavigation.down, direction), + else => return error.TestUnexpectedResult, + } + switch (classifyTuiEscapeSuffix("OA")) { + .navigation => |direction| try std.testing.expectEqual(TuiNavigation.up, direction), + else => return error.TestUnexpectedResult, + } +} + +test "Scenario: Given unrelated tty escape suffixes when classifying them then they are ignored instead of acting like quit" { + try std.testing.expectEqual(TuiEscapeClassification.ignore, classifyTuiEscapeSuffix("x")); + try std.testing.expectEqual(TuiEscapeClassification.ignore, classifyTuiEscapeSuffix("[200~")); + try std.testing.expectEqual(TuiEscapeClassification.incomplete, classifyTuiEscapeSuffix("[")); +} + +test "Scenario: Given shared TUI screen lifecycle when writing it then switch and remove can stay inside the alternate screen" { + const gpa = std.testing.allocator; + var aw: std.Io.Writer.Allocating = .init(gpa); + defer aw.deinit(); + + try writeTuiEnterTo(&aw.writer); + try writeTuiExitTo(&aw.writer); + + try std.testing.expectEqualStrings( + "\x1b[?1049h\x1b[?25l" ++ + "\x1b[H\x1b[J" ++ + "\x1b[?25h\x1b[?1049l", + aw.written(), + ); +} + +test "Scenario: Given shared TUI frame redraw when writing it then it clears only the alternate screen frame instead of appending full screens" { + const gpa = std.testing.allocator; + var aw: std.Io.Writer.Allocating = .init(gpa); + defer aw.deinit(); + + try writeTuiResetFrameTo(&aw.writer); + + try std.testing.expectEqualStrings("\x1b[H\x1b[J", aw.written()); + try std.testing.expect(std.mem.indexOf(u8, aw.written(), "\x1b[2J\x1b[H") == null); +} + fn colorEnabled() bool { return fs.File.stdout().isTty(); } @@ -1133,6 +1381,27 @@ pub fn printRemoveSummary(labels: []const []const u8) !void { try out.flush(); } +pub fn printSwitchedAccount( + allocator: std.mem.Allocator, + reg: *registry.Registry, + account_key: []const u8, +) !void { + const label = if (registry.findAccountIndexByAccountKey(reg, account_key)) |idx| + try display_rows.buildPreferredAccountLabelAlloc(allocator, ®.accounts.items[idx], reg.accounts.items[idx].email) + else + try allocator.dupe(u8, account_key); + defer allocator.free(label); + + var stdout: io_util.Stdout = undefined; + stdout.init(); + const out = stdout.out(); + const use_color = colorEnabled(); + if (use_color) try out.writeAll(ansi.bold_green); + try out.print("Switched to {s}\n", .{label}); + if (use_color) try out.writeAll(ansi.reset); + try out.flush(); +} + fn writeCodexLoginLaunchFailureHint(err_name: []const u8, use_color: bool) !void { var buffer: [512]u8 = undefined; var writer = fs.File.stderr().writer(&buffer); @@ -1204,6 +1473,208 @@ pub fn selectAccountWithUsageOverrides( selectInteractive(allocator, reg, usage_overrides) catch selectWithNumbers(allocator, reg, usage_overrides); } +pub const SwitchSelectionDisplay = struct { + reg: *registry.Registry, + usage_overrides: ?[]const ?[]const u8, +}; + +pub const OwnedSwitchSelectionDisplay = struct { + reg: registry.Registry, + usage_overrides: []?[]const u8, + + pub fn borrowed(self: *@This()) SwitchSelectionDisplay { + return .{ + .reg = &self.reg, + .usage_overrides = self.usage_overrides, + }; + } + + pub fn deinit(self: *@This(), allocator: std.mem.Allocator) void { + for (self.usage_overrides) |usage_override| { + if (usage_override) |value| allocator.free(value); + } + allocator.free(self.usage_overrides); + self.reg.deinit(allocator); + self.* = undefined; + } +}; + +pub const SwitchLiveController = struct { + context: *anyopaque, + maybe_start_refresh: *const fn (context: *anyopaque) anyerror!void, + maybe_take_updated_display: *const fn (context: *anyopaque) anyerror!?OwnedSwitchSelectionDisplay, + build_status_line: *const fn ( + context: *anyopaque, + allocator: std.mem.Allocator, + display: SwitchSelectionDisplay, + ) anyerror![]u8, +}; + +pub fn selectAccountWithLiveUpdates( + allocator: std.mem.Allocator, + initial_display: OwnedSwitchSelectionDisplay, + controller: SwitchLiveController, +) !?[]const u8 { + var current_display = initial_display; + defer current_display.deinit(allocator); + if (current_display.reg.accounts.items.len == 0) return null; + + if (comptime builtin.os.tag == .windows) { + const selected_account_key = try selectWithNumbers(allocator, ¤t_display.reg, current_display.usage_overrides); + return try dupeOptionalAccountKey(allocator, selected_account_key); + } + + var tui = TuiSession.init() catch { + const selected_account_key = try selectWithNumbers(allocator, ¤t_display.reg, current_display.usage_overrides); + return try dupeOptionalAccountKey(allocator, selected_account_key); + }; + defer tui.deinit(); + + const out = tui.out(); + const use_color = tui.tty.isTty(); + const ui_tick_ms: i32 = 1000; + + var selected_account_key = if (current_display.reg.active_account_key) |key| + try allocator.dupe(u8, key) + else + null; + defer if (selected_account_key) |key| allocator.free(key); + + var number_buf: [8]u8 = undefined; + var number_len: usize = 0; + + while (true) { + if (try controller.maybe_take_updated_display(controller.context)) |updated| { + current_display.deinit(allocator); + current_display = updated; + } + + const borrowed = current_display.borrowed(); + var rows = try buildSwitchRowsWithUsageOverrides(allocator, borrowed.reg, borrowed.usage_overrides); + defer rows.deinit(allocator); + if (rows.selectable_row_indices.len == 0) return null; + + var selected_idx = if (selected_account_key) |key| + selectableIndexForAccountKey(&rows, borrowed.reg, key) orelse activeSelectableIndex(&rows) orelse 0 + else + activeSelectableIndex(&rows) orelse 0; + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selected_idx); + + const status_line = try controller.build_status_line(controller.context, allocator, borrowed); + defer allocator.free(status_line); + + try tui.resetFrame(); + try renderSwitchScreen( + out, + borrowed.reg, + rows.items, + @max(@as(usize, 2), indexWidth(rows.selectable_row_indices.len)), + rows.widths, + selected_idx, + use_color, + status_line, + ); + try out.flush(); + + var fds = [_]std.posix.pollfd{.{ + .fd = tui.tty.handle, + .events = std.posix.POLL.IN, + .revents = 0, + }}; + const ready = try std.posix.poll(&fds, ui_tick_ms); + if (ready == 0) { + try controller.maybe_start_refresh(controller.context); + continue; + } + if ((fds[0].revents & tui_poll_error_mask) != 0) return null; + + var b: [8]u8 = undefined; + const n = try tui.read(&b); + if (n == 0) return null; + + var i: usize = 0; + while (i < n) : (i += 1) { + if (b[i] == 0x1b) { + const escape = try readTuiEscapeAction( + tui.tty, + b[i + 1 .. n], + tui_poll_error_mask, + tui_escape_sequence_timeout_ms, + ); + switch (escape.action) { + .move_up => { + if (selected_idx > 0) { + selected_idx -= 1; + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selected_idx); + number_len = 0; + } + }, + .move_down => { + if (selected_idx + 1 < rows.selectable_row_indices.len) { + selected_idx += 1; + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selected_idx); + number_len = 0; + } + }, + .quit => return null, + .ignore => {}, + } + i += escape.buffered_bytes_consumed; + continue; + } + + if (b[i] == '\r' or b[i] == '\n') { + if (number_len > 0) { + const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; + if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { + return try dupSelectedAccountKey(allocator, &rows, borrowed.reg, parsed - 1); + } + } + return try dupSelectedAccountKey(allocator, &rows, borrowed.reg, selected_idx); + } + if (isQuitKey(b[i])) return null; + + if (b[i] == 'k' and selected_idx > 0) { + selected_idx -= 1; + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selected_idx); + number_len = 0; + continue; + } + if (b[i] == 'j' and selected_idx + 1 < rows.selectable_row_indices.len) { + selected_idx += 1; + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selected_idx); + number_len = 0; + continue; + } + if (b[i] == 0x7f or b[i] == 0x08) { + if (number_len > 0) { + number_len -= 1; + if (number_len > 0) { + const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; + if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { + selected_idx = parsed - 1; + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selected_idx); + } + } + } + continue; + } + if (b[i] >= '0' and b[i] <= '9') { + if (number_len < number_buf.len) { + number_buf[number_len] = b[i]; + number_len += 1; + const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; + if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { + selected_idx = parsed - 1; + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selected_idx); + } + } + continue; + } + } + } +} + pub fn selectAccountFromIndices(allocator: std.mem.Allocator, reg: *registry.Registry, indices: []const usize) !?[]const u8 { return selectAccountFromIndicesWithUsageOverrides(allocator, reg, indices, null); } @@ -1265,11 +1736,48 @@ fn accountIdForSelectable(rows: *const SwitchRows, reg: *registry.Registry, sele return reg.accounts.items[account_idx].account_key; } +fn dupSelectedAccountKey( + allocator: std.mem.Allocator, + rows: *const SwitchRows, + reg: *registry.Registry, + selectable_idx: usize, +) ![]const u8 { + return try allocator.dupe(u8, accountIdForSelectable(rows, reg, selectable_idx)); +} + +fn dupeOptionalAccountKey(allocator: std.mem.Allocator, account_key: ?[]const u8) !?[]const u8 { + return if (account_key) |value| try allocator.dupe(u8, value) else null; +} + fn accountIndexForSelectable(rows: *const SwitchRows, selectable_idx: usize) usize { const row_idx = rows.selectable_row_indices[selectable_idx]; return rows.items[row_idx].account_index.?; } +fn selectableIndexForAccountKey( + rows: *const SwitchRows, + reg: *registry.Registry, + account_key: []const u8, +) ?usize { + for (rows.selectable_row_indices, 0..) |row_idx, selectable_idx| { + const account_idx = rows.items[row_idx].account_index orelse continue; + if (std.mem.eql(u8, reg.accounts.items[account_idx].account_key, account_key)) return selectable_idx; + } + return null; +} + +fn replaceSelectedAccountKeyForSelectable( + allocator: std.mem.Allocator, + selected_account_key: *?[]u8, + rows: *const SwitchRows, + reg: *registry.Registry, + selectable_idx: usize, +) !void { + const next_key = try allocator.dupe(u8, accountIdForSelectable(rows, reg, selectable_idx)); + if (selected_account_key.*) |current_key| allocator.free(current_key); + selected_account_key.* = next_key; +} + fn selectWithNumbers( allocator: std.mem.Allocator, reg: *registry.Registry, @@ -1350,31 +1858,19 @@ fn selectInteractiveFromIndices( var rows = try buildSwitchRowsFromIndicesWithUsageOverrides(allocator, reg, indices, usage_overrides); defer rows.deinit(allocator); - var tty = try fs.cwd().openFile("/dev/tty", .{}); - defer tty.close(); - - const term = try std.posix.tcgetattr(tty.handle); - var raw = term; - raw.lflag.ICANON = false; - raw.lflag.ECHO = false; - raw.cc[@intFromEnum(std.c.V.MIN)] = 1; - raw.cc[@intFromEnum(std.c.V.TIME)] = 0; - try std.posix.tcsetattr(tty.handle, .FLUSH, raw); - defer std.posix.tcsetattr(tty.handle, .FLUSH, term) catch {}; - - var stdout: io_util.Stdout = undefined; - stdout.init(); - const out = stdout.out(); + var tui = try TuiSession.init(); + defer tui.deinit(); + const out = tui.out(); const active_idx = activeSelectableIndex(&rows); var idx: usize = active_idx orelse 0; var number_buf: [8]u8 = undefined; var number_len: usize = 0; - const use_color = colorEnabled(); + const use_color = tui.tty.isTty(); const idx_width = @max(@as(usize, 2), indexWidth(rows.selectable_row_indices.len)); const widths = rows.widths; while (true) { - try out.writeAll("\x1b[2J\x1b[H"); + try tui.resetFrame(); try out.writeAll("Select account to activate:\n\n"); try renderSwitchList(out, reg, rows.items, idx_width, widths, idx, use_color); try out.writeAll("\n"); @@ -1384,23 +1880,34 @@ fn selectInteractiveFromIndices( try out.flush(); var b: [8]u8 = undefined; - const n = try tty.read(&b); + const n = try tui.read(&b); var i: usize = 0; while (i < n) : (i += 1) { if (b[i] == 0x1b) { - if (i + 2 < n and b[i + 1] == '[') { - const code = b[i + 2]; - if (code == 'A' and idx > 0) { - idx -= 1; - number_len = 0; - } else if (code == 'B' and idx + 1 < rows.selectable_row_indices.len) { - idx += 1; - number_len = 0; - } - i += 2; - continue; + const escape = try readTuiEscapeAction( + tui.tty, + b[i + 1 .. n], + tui_poll_error_mask, + tui_escape_sequence_timeout_ms, + ); + switch (escape.action) { + .move_up => { + if (idx > 0) { + idx -= 1; + number_len = 0; + } + }, + .move_down => { + if (idx + 1 < rows.selectable_row_indices.len) { + idx += 1; + number_len = 0; + } + }, + .quit => return null, + .ignore => {}, } - return null; + i += escape.buffered_bytes_consumed; + continue; } if (b[i] == '\r' or b[i] == '\n') { @@ -1533,21 +2040,9 @@ fn selectInteractive( var rows = try buildSwitchRowsWithUsageOverrides(allocator, reg, usage_overrides); defer rows.deinit(allocator); - var tty = try fs.cwd().openFile("/dev/tty", .{}); - defer tty.close(); - - const term = try std.posix.tcgetattr(tty.handle); - var raw = term; - raw.lflag.ICANON = false; - raw.lflag.ECHO = false; - raw.cc[@intFromEnum(std.c.V.MIN)] = 1; - raw.cc[@intFromEnum(std.c.V.TIME)] = 0; - try std.posix.tcsetattr(tty.handle, .FLUSH, raw); - defer std.posix.tcsetattr(tty.handle, .FLUSH, term) catch {}; - - var stdout: io_util.Stdout = undefined; - stdout.init(); - const out = stdout.out(); + var tui = try TuiSession.init(); + defer tui.deinit(); + const out = tui.out(); const active_idx = activeSelectableIndex(&rows); var idx: usize = active_idx orelse 0; var number_buf: [8]u8 = undefined; @@ -1557,7 +2052,7 @@ fn selectInteractive( const widths = rows.widths; while (true) { - try out.writeAll("\x1b[2J\x1b[H"); + try tui.resetFrame(); try out.writeAll("Select account to activate:\n\n"); try renderSwitchList(out, reg, rows.items, idx_width, widths, idx, use_color); try out.writeAll("\n"); @@ -1567,23 +2062,34 @@ fn selectInteractive( try out.flush(); var b: [8]u8 = undefined; - const n = try tty.read(&b); + const n = try tui.read(&b); var i: usize = 0; while (i < n) : (i += 1) { if (b[i] == 0x1b) { - if (i + 2 < n and b[i + 1] == '[') { - const code = b[i + 2]; - if (code == 'A' and idx > 0) { - idx -= 1; - number_len = 0; - } else if (code == 'B' and idx + 1 < rows.selectable_row_indices.len) { - idx += 1; - number_len = 0; - } - i += 2; - continue; + const escape = try readTuiEscapeAction( + tui.tty, + b[i + 1 .. n], + tui_poll_error_mask, + tui_escape_sequence_timeout_ms, + ); + switch (escape.action) { + .move_up => { + if (idx > 0) { + idx -= 1; + number_len = 0; + } + }, + .move_down => { + if (idx + 1 < rows.selectable_row_indices.len) { + idx += 1; + number_len = 0; + } + }, + .quit => return null, + .ignore => {}, } - return null; + i += escape.buffered_bytes_consumed; + continue; } if (b[i] == '\r' or b[i] == '\n') { @@ -1642,25 +2148,13 @@ fn selectRemoveInteractive( var rows = try buildSwitchRowsWithUsageOverrides(allocator, reg, usage_overrides); defer rows.deinit(allocator); - var tty = try fs.cwd().openFile("/dev/tty", .{}); - defer tty.close(); - - const term = try std.posix.tcgetattr(tty.handle); - var raw = term; - raw.lflag.ICANON = false; - raw.lflag.ECHO = false; - raw.cc[@intFromEnum(std.c.V.MIN)] = 1; - raw.cc[@intFromEnum(std.c.V.TIME)] = 0; - try std.posix.tcsetattr(tty.handle, .FLUSH, raw); - defer std.posix.tcsetattr(tty.handle, .FLUSH, term) catch {}; - var checked = try allocator.alloc(bool, rows.selectable_row_indices.len); defer allocator.free(checked); @memset(checked, false); - var stdout: io_util.Stdout = undefined; - stdout.init(); - const out = stdout.out(); + var tui = try TuiSession.init(); + defer tui.deinit(); + const out = tui.out(); var idx: usize = 0; var number_buf: [8]u8 = undefined; var number_len: usize = 0; @@ -1669,7 +2163,7 @@ fn selectRemoveInteractive( const widths = rows.widths; while (true) { - try out.writeAll("\x1b[2J\x1b[H"); + try tui.resetFrame(); try out.writeAll("Select accounts to delete:\n\n"); try renderRemoveList(out, reg, rows.items, idx_width, widths, idx, checked, use_color); try out.writeAll("\n"); @@ -1679,23 +2173,34 @@ fn selectRemoveInteractive( try out.flush(); var b: [8]u8 = undefined; - const n = try tty.read(&b); + const n = try tui.read(&b); var i: usize = 0; while (i < n) : (i += 1) { if (b[i] == 0x1b) { - if (i + 2 < n and b[i + 1] == '[') { - const code = b[i + 2]; - if (code == 'A' and idx > 0) { - idx -= 1; - number_len = 0; - } else if (code == 'B' and idx + 1 < rows.selectable_row_indices.len) { - idx += 1; - number_len = 0; - } - i += 2; - continue; + const escape = try readTuiEscapeAction( + tui.tty, + b[i + 1 .. n], + tui_poll_error_mask, + tui_escape_sequence_timeout_ms, + ); + switch (escape.action) { + .move_up => { + if (idx > 0) { + idx -= 1; + number_len = 0; + } + }, + .move_down => { + if (idx + 1 < rows.selectable_row_indices.len) { + idx += 1; + number_len = 0; + } + }, + .quit => return null, + .ignore => {}, } - return null; + i += escape.buffered_bytes_consumed; + continue; } if (b[i] == '\r' or b[i] == '\n') { @@ -1756,6 +2261,30 @@ fn selectRemoveInteractive( } } +fn renderSwitchScreen( + out: *std.Io.Writer, + reg: *registry.Registry, + rows: []const SwitchRow, + idx_width: usize, + widths: SwitchWidths, + selected: ?usize, + use_color: bool, + status_line: []const u8, +) !void { + try out.writeAll("Select account to activate:\n\n"); + try renderSwitchList(out, reg, rows, idx_width, widths, selected, use_color); + try out.writeAll("\n"); + if (status_line.len != 0) { + if (use_color) try out.writeAll(ansi.dim); + try out.writeAll(status_line); + try out.writeAll("\n"); + if (use_color) try out.writeAll(ansi.reset); + } + if (use_color) try out.writeAll(ansi.dim); + try out.writeAll("Keys: ↑/↓ or j/k, Enter select, 1-9 type, Backspace edit, Esc or q quit\n"); + if (use_color) try out.writeAll(ansi.reset); +} + fn renderSwitchList( out: *std.Io.Writer, reg: *registry.Registry, diff --git a/src/main.zig b/src/main.zig index a0ac6f4..17bcfef 100644 --- a/src/main.zig +++ b/src/main.zig @@ -12,12 +12,16 @@ const auth = @import("auth.zig"); const auto = @import("auto.zig"); const format = @import("format.zig"); const io_util = @import("io_util.zig"); +const timefmt = @import("timefmt.zig"); const usage_api = @import("usage_api.zig"); const skip_service_reconcile_env = "CODEX_AUTH_SKIP_SERVICE_RECONCILE"; const account_name_refresh_only_env = "CODEX_AUTH_REFRESH_ACCOUNT_NAMES_ONLY"; const disable_background_account_name_refresh_env = "CODEX_AUTH_DISABLE_BACKGROUND_ACCOUNT_NAME_REFRESH"; const foreground_usage_refresh_concurrency: usize = 3; +const switch_live_api_refresh_interval_ms: i64 = 30_000; +const switch_live_local_refresh_interval_ms: i64 = 10_000; +const switch_live_stored_refresh_interval_ms: i64 = 10_000; const AccountFetchFn = *const fn ( allocator: std.mem.Allocator, @@ -81,6 +85,13 @@ pub const ForegroundUsageRefreshState = struct { } }; +const SwitchLiveRecordFields = struct { + account_name: ?[]const u8, + last_usage: ?registry.RateLimitSnapshot, + last_usage_at: ?i64, + last_local_rollout: ?registry.RolloutSignature, +}; + const SwitchQueryResolution = union(enum) { not_found, direct: []const u8, @@ -339,7 +350,7 @@ pub fn refreshForegroundUsageForDisplayWithApiFetcher( reg: *registry.Registry, usage_fetcher: UsageFetchDetailedFn, ) !ForegroundUsageRefreshState { - return refreshForegroundUsageForDisplayWithApiFetcherWithPoolInitAndDebugUsingApiEnabled( + return refreshForegroundUsageForDisplayWithApiFetcherWithPoolInitAndDebugUsingApiEnabledAndPersist( allocator, codex_home, reg, @@ -347,6 +358,7 @@ pub fn refreshForegroundUsageForDisplayWithApiFetcher( initForegroundUsagePool, null, reg.api.usage, + true, ); } @@ -357,7 +369,7 @@ pub fn refreshForegroundUsageForDisplayWithApiFetcherWithPoolInit( usage_fetcher: UsageFetchDetailedFn, pool_init: ForegroundUsagePoolInitFn, ) !ForegroundUsageRefreshState { - return refreshForegroundUsageForDisplayWithApiFetcherWithPoolInitAndDebugUsingApiEnabled( + return refreshForegroundUsageForDisplayWithApiFetcherWithPoolInitAndDebugUsingApiEnabledAndPersist( allocator, codex_home, reg, @@ -365,6 +377,7 @@ pub fn refreshForegroundUsageForDisplayWithApiFetcherWithPoolInit( pool_init, null, reg.api.usage, + true, ); } @@ -376,7 +389,7 @@ pub fn refreshForegroundUsageForDisplayWithApiFetcherWithPoolInitAndDebug( pool_init: ForegroundUsagePoolInitFn, debug_logger: ?*ForegroundUsageDebugLogger, ) !ForegroundUsageRefreshState { - return refreshForegroundUsageForDisplayWithApiFetcherWithPoolInitAndDebugUsingApiEnabled( + return refreshForegroundUsageForDisplayWithApiFetcherWithPoolInitAndDebugUsingApiEnabledAndPersist( allocator, codex_home, reg, @@ -384,6 +397,7 @@ pub fn refreshForegroundUsageForDisplayWithApiFetcherWithPoolInitAndDebug( pool_init, debug_logger, reg.api.usage, + true, ); } @@ -395,6 +409,28 @@ fn refreshForegroundUsageForDisplayWithApiFetcherWithPoolInitAndDebugUsingApiEna pool_init: ForegroundUsagePoolInitFn, debug_logger: ?*ForegroundUsageDebugLogger, usage_api_enabled: bool, +) !ForegroundUsageRefreshState { + return refreshForegroundUsageForDisplayWithApiFetcherWithPoolInitAndDebugUsingApiEnabledAndPersist( + allocator, + codex_home, + reg, + usage_fetcher, + pool_init, + debug_logger, + usage_api_enabled, + true, + ); +} + +fn refreshForegroundUsageForDisplayWithApiFetcherWithPoolInitAndDebugUsingApiEnabledAndPersist( + allocator: std.mem.Allocator, + codex_home: []const u8, + reg: *registry.Registry, + usage_fetcher: UsageFetchDetailedFn, + pool_init: ForegroundUsagePoolInitFn, + debug_logger: ?*ForegroundUsageDebugLogger, + usage_api_enabled: bool, + persist_registry: bool, ) !ForegroundUsageRefreshState { var state = try initForegroundUsageRefreshState(allocator, reg.accounts.items.len); errdefer state.deinit(allocator); @@ -407,7 +443,7 @@ fn refreshForegroundUsageForDisplayWithApiFetcherWithPoolInitAndDebugUsingApiEna if (!usage_api_enabled) { state.local_only_mode = true; if (try auto.refreshActiveUsage(allocator, codex_home, reg)) { - try registry.saveRegistry(allocator, codex_home, reg); + if (persist_registry) try registry.saveRegistry(allocator, codex_home, reg); } if (debug_logger) |logger| { try logger.print("[debug] usage refresh skipped: mode=local-only; only the active account can refresh from local rollout data\n", .{}); @@ -484,7 +520,7 @@ fn refreshForegroundUsageForDisplayWithApiFetcherWithPoolInitAndDebugUsingApiEna } } - if (registry_changed) { + if (persist_registry and registry_changed) { try registry.saveRegistry(allocator, codex_home, reg); } @@ -819,6 +855,26 @@ fn maybeRefreshForegroundAccountNamesWithAccountApiEnabled( fetcher: AccountFetchFn, account_api_enabled: bool, ) !void { + _ = try maybeRefreshForegroundAccountNamesWithAccountApiEnabledAndPersist( + allocator, + codex_home, + reg, + target, + fetcher, + account_api_enabled, + true, + ); +} + +fn maybeRefreshForegroundAccountNamesWithAccountApiEnabledAndPersist( + allocator: std.mem.Allocator, + codex_home: []const u8, + reg: *registry.Registry, + target: ForegroundUsageRefreshTarget, + fetcher: AccountFetchFn, + account_api_enabled: bool, + persist_registry: bool, +) !bool { const changed = switch (target) { .list, .remove_account => try refreshAccountNamesForListWithAccountApiEnabled( allocator, @@ -835,8 +891,9 @@ fn maybeRefreshForegroundAccountNamesWithAccountApiEnabled( account_api_enabled, ), }; - if (!changed) return; - try registry.saveRegistry(allocator, codex_home, reg); + if (!changed) return false; + if (persist_registry) try registry.saveRegistry(allocator, codex_home, reg); + return true; } fn defaultAccountFetcher( @@ -1462,12 +1519,12 @@ fn handleImport(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. } fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.SwitchOptions) !void { - var reg = try registry.loadRegistry(allocator, codex_home); - defer reg.deinit(allocator); - if (try registry.syncActiveAccountFromAuth(allocator, codex_home, ®)) { - try registry.saveRegistry(allocator, codex_home, ®); - } if (opts.query) |query| { + var reg = try registry.loadRegistry(allocator, codex_home); + defer reg.deinit(allocator); + if (try registry.syncActiveAccountFromAuth(allocator, codex_home, ®)) { + try registry.saveRegistry(allocator, codex_home, ®); + } std.debug.assert(opts.api_mode == .default); var resolution = try resolveSwitchQueryLocally(allocator, ®, query); @@ -1489,52 +1546,583 @@ fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. if (selected_account_key == null) return; try registry.activateAccountByKey(allocator, codex_home, ®, selected_account_key.?); try registry.saveRegistry(allocator, codex_home, ®); + try cli.printSwitchedAccount(allocator, ®, selected_account_key.?); return; } - if (apiModeUsesStoredDataOnly(opts.api_mode)) { - const selected_account_key = try cli.selectAccount(allocator, ®); - if (selected_account_key == null) return; - try registry.activateAccountByKey(allocator, codex_home, ®, selected_account_key.?); - try registry.saveRegistry(allocator, codex_home, ®); - return; + const live_allocator = std.heap.smp_allocator; + + const loaded = try loadSwitchSelectionDisplay(live_allocator, codex_home, opts.api_mode); + var initial_display: ?cli.OwnedSwitchSelectionDisplay = loaded.display; + errdefer if (initial_display) |*display| display.deinit(live_allocator); + + const selected_account_key = blk: { + var runtime = SwitchLiveRuntime.init(live_allocator, codex_home, opts.api_mode, loaded.policy); + defer runtime.deinit(); + + const controller: cli.SwitchLiveController = .{ + .context = @ptrCast(&runtime), + .maybe_start_refresh = switchLiveRuntimeMaybeStartRefresh, + .maybe_take_updated_display = switchLiveRuntimeMaybeTakeUpdatedDisplay, + .build_status_line = switchLiveRuntimeBuildStatusLine, + }; + + const transferred_display = initial_display.?; + initial_display = null; + break :blk try cli.selectAccountWithLiveUpdates(live_allocator, transferred_display, controller); + }; + defer if (selected_account_key) |account_key| live_allocator.free(@constCast(account_key)); + + if (selected_account_key == null) return; + + var reg = try registry.loadRegistry(live_allocator, codex_home); + defer reg.deinit(live_allocator); + if (try registry.syncActiveAccountFromAuth(live_allocator, codex_home, ®)) { + try registry.saveRegistry(live_allocator, codex_home, ®); } + try registry.activateAccountByKey(live_allocator, codex_home, ®, selected_account_key.?); + try registry.saveRegistry(live_allocator, codex_home, ®); + try cli.printSwitchedAccount(live_allocator, ®, selected_account_key.?); +} - const usage_api_enabled = apiModeUsesApi(reg.api.usage, opts.api_mode); - const account_api_enabled = apiModeUsesApi(reg.api.account, opts.api_mode); +const SwitchLiveRefreshPolicy = struct { + usage_api_enabled: bool, + account_api_enabled: bool, + interval_ms: i64, + label: []const u8, +}; + +const SwitchLoadedDisplay = struct { + display: cli.OwnedSwitchSelectionDisplay, + policy: SwitchLiveRefreshPolicy, +}; + +const SwitchLiveRuntime = struct { + allocator: std.mem.Allocator, + codex_home: []const u8, + api_mode: cli.ApiMode, + io_impl: std.Io.Threaded, + mutex: std.Io.Mutex = .init, + refresh_task: ?std.Io.Future(void) = null, + updated_display: ?cli.OwnedSwitchSelectionDisplay = null, + in_flight: bool = false, + next_refresh_not_before_ms: i64, + last_refresh_started_at_ms: ?i64 = null, + last_refresh_finished_at_ms: ?i64 = null, + last_refresh_duration_ms: ?i64 = null, + last_refresh_error_name: ?[]u8 = null, + refresh_interval_ms: i64, + mode_label: []const u8, + + fn init( + allocator: std.mem.Allocator, + codex_home: []const u8, + api_mode: cli.ApiMode, + initial_policy: SwitchLiveRefreshPolicy, + ) @This() { + const io_impl = std.Io.Threaded.init(allocator, .{ + .concurrent_limit = .limited(1), + }); + const now_ms = time_compat.milliTimestamp(); + return .{ + .allocator = allocator, + .codex_home = codex_home, + .api_mode = api_mode, + .io_impl = io_impl, + .next_refresh_not_before_ms = now_ms + initial_policy.interval_ms, + .refresh_interval_ms = initial_policy.interval_ms, + .mode_label = initial_policy.label, + }; + } + + fn deinit(self: *@This()) void { + self.awaitRefresh(); + if (self.updated_display) |*display| display.deinit(self.allocator); + if (self.last_refresh_error_name) |name| self.allocator.free(name); + self.io_impl.deinit(); + self.* = undefined; + } + + fn awaitRefresh(self: *@This()) void { + const io = self.io_impl.io(); + var future: ?std.Io.Future(void) = null; + self.mutex.lockUncancelable(io); + if (self.refresh_task) |task| { + future = task; + self.refresh_task = null; + } + self.mutex.unlock(io); + if (future) |*task| task.await(io); + } + + fn maybeStartRefresh(self: *@This()) void { + const io = self.io_impl.io(); + const now_ms = time_compat.milliTimestamp(); + + self.mutex.lockUncancelable(io); + if (self.in_flight or self.refresh_task != null or now_ms < self.next_refresh_not_before_ms) { + self.mutex.unlock(io); + return; + } + self.in_flight = true; + self.last_refresh_started_at_ms = now_ms; + self.mutex.unlock(io); + + const future = io.concurrent(runSwitchLiveRefreshRound, .{self}) catch |err| { + const finished_ms = time_compat.milliTimestamp(); + const error_name = self.allocator.dupe(u8, @errorName(err)) catch null; + + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + if (self.last_refresh_error_name) |name| self.allocator.free(name); + self.last_refresh_error_name = error_name; + self.last_refresh_finished_at_ms = finished_ms; + self.last_refresh_duration_ms = finished_ms - now_ms; + self.next_refresh_not_before_ms = finished_ms + self.refresh_interval_ms; + self.in_flight = false; + return; + }; + + self.mutex.lockUncancelable(io); + self.refresh_task = future; + self.mutex.unlock(io); + } + + fn maybeTakeUpdatedDisplay(self: *@This()) ?cli.OwnedSwitchSelectionDisplay { + const io = self.io_impl.io(); + var future: ?std.Io.Future(void) = null; + var display: ?cli.OwnedSwitchSelectionDisplay = null; + + self.mutex.lockUncancelable(io); + if (!self.in_flight and self.refresh_task != null) { + future = self.refresh_task; + self.refresh_task = null; + } + if (self.updated_display) |owned_display| { + display = owned_display; + self.updated_display = null; + } + self.mutex.unlock(io); + + if (future) |*task| task.await(io); + return display; + } + + fn buildStatusLine(self: *@This(), allocator: std.mem.Allocator, display: cli.SwitchSelectionDisplay) ![]u8 { + const io = self.io_impl.io(); + const now_ms = time_compat.milliTimestamp(); + const now_s = time_compat.timestamp(); + + var in_flight = false; + var next_refresh_not_before_ms: i64 = now_ms; + var last_round_duration_ms: ?i64 = null; + var mode_label: []const u8 = "stored"; + var refresh_error_name: ?[]u8 = null; + + self.mutex.lockUncancelable(io); + in_flight = self.in_flight; + next_refresh_not_before_ms = self.next_refresh_not_before_ms; + last_round_duration_ms = self.last_refresh_duration_ms; + mode_label = self.mode_label; + if (self.last_refresh_error_name) |error_name| { + refresh_error_name = try allocator.dupe(u8, error_name); + } + self.mutex.unlock(io); + defer if (refresh_error_name) |value| allocator.free(value); + + const refresh_state = if (in_flight) + try allocator.dupe(u8, "running") + else if (next_refresh_not_before_ms <= now_ms) + try allocator.dupe(u8, "due") + else + try std.fmt.allocPrint(allocator, "in {d}s", .{@divFloor((next_refresh_not_before_ms - now_ms) + 999, 1000)}); + defer allocator.free(refresh_state); + + const round_state = if (last_round_duration_ms) |duration_ms| + try std.fmt.allocPrint(allocator, "{d}s", .{@divFloor(duration_ms + 999, 1000)}) + else + try allocator.dupe(u8, "-"); + defer allocator.free(round_state); + + const error_suffix = if (refresh_error_name) |value| + try std.fmt.allocPrint(allocator, " | Error: {s}", .{value}) + else + try allocator.dupe(u8, ""); + defer allocator.free(error_suffix); + + const active_account_key = trackedActiveAccountKey(display.reg); + if (active_account_key == null) { + return std.fmt.allocPrint( + allocator, + "Active: - | 5H - | Weekly - | Last - | Mode: {s} | Refresh: {s} | Last round: {s}{s}", + .{ + mode_label, + refresh_state, + round_state, + error_suffix, + }, + ); + } + + const active_idx = registry.findAccountIndexByAccountKey(display.reg, active_account_key.?) orelse { + return std.fmt.allocPrint( + allocator, + "Active: - | 5H - | Weekly - | Last - | Mode: {s} | Refresh: {s} | Last round: {s}{s}", + .{ + mode_label, + refresh_state, + round_state, + error_suffix, + }, + ); + }; + const rec = &display.reg.accounts.items[active_idx]; + const active_label = try display_rows.buildPreferredAccountLabelAlloc(allocator, rec, rec.email); + defer allocator.free(active_label); + const remaining_5h = try formatRemainingPercentAlloc(allocator, registry.resolveRateWindow(rec.last_usage, 300, true)); + defer allocator.free(remaining_5h); + const remaining_weekly = try formatRemainingPercentAlloc(allocator, registry.resolveRateWindow(rec.last_usage, 10080, false)); + defer allocator.free(remaining_weekly); + const last_activity = try timefmt.formatRelativeTimeOrDashAlloc(allocator, rec.last_used_at, now_s); + defer allocator.free(last_activity); + + return std.fmt.allocPrint( + allocator, + "Active: {s} | 5H {s} | Weekly {s} | Last {s} | Mode: {s} | Refresh: {s} | Last round: {s}{s}", + .{ + active_label, + remaining_5h, + remaining_weekly, + last_activity, + mode_label, + refresh_state, + round_state, + error_suffix, + }, + ); + } +}; + +fn switchLiveRefreshPolicy(reg: *const registry.Registry, api_mode: cli.ApiMode) SwitchLiveRefreshPolicy { + if (apiModeUsesStoredDataOnly(api_mode)) { + return .{ + .usage_api_enabled = false, + .account_api_enabled = false, + .interval_ms = switch_live_stored_refresh_interval_ms, + .label = "stored", + }; + } + + const usage_api_enabled = apiModeUsesApi(reg.api.usage, api_mode); + const account_api_enabled = apiModeUsesApi(reg.api.account, api_mode); + if (usage_api_enabled or account_api_enabled) { + return .{ + .usage_api_enabled = usage_api_enabled, + .account_api_enabled = account_api_enabled, + .interval_ms = switch_live_api_refresh_interval_ms, + .label = "api", + }; + } + + return .{ + .usage_api_enabled = false, + .account_api_enabled = false, + .interval_ms = switch_live_local_refresh_interval_ms, + .label = "local", + }; +} + +fn findAccountIndexByAccountKeyConst(reg: *const registry.Registry, account_key: []const u8) ?usize { + for (reg.accounts.items, 0..) |rec, idx| { + if (std.mem.eql(u8, rec.account_key, account_key)) return idx; + } + return null; +} + +fn optionalBytesEqual(a: ?[]const u8, b: ?[]const u8) bool { + if (a == null and b == null) return true; + if (a == null or b == null) return false; + return std.mem.eql(u8, a.?, b.?); +} + +fn switchLiveUsageFieldsEqual( + maybe_a: ?*const registry.AccountRecord, + maybe_b: ?*const registry.AccountRecord, +) bool { + const a_usage = if (maybe_a) |rec| rec.last_usage else null; + const b_usage = if (maybe_b) |rec| rec.last_usage else null; + if (!registry.rateLimitSnapshotsEqual(a_usage, b_usage)) return false; + + const a_last_usage_at = if (maybe_a) |rec| rec.last_usage_at else null; + const b_last_usage_at = if (maybe_b) |rec| rec.last_usage_at else null; + if (a_last_usage_at != b_last_usage_at) return false; + + const a_last_local_rollout = if (maybe_a) |rec| rec.last_local_rollout else null; + const b_last_local_rollout = if (maybe_b) |rec| rec.last_local_rollout else null; + return registry.rolloutSignaturesEqual(a_last_local_rollout, b_last_local_rollout); +} + +fn switchLiveAccountNameEqual( + maybe_a: ?*const registry.AccountRecord, + maybe_b: ?*const registry.AccountRecord, +) bool { + const a_account_name = if (maybe_a) |rec| rec.account_name else null; + const b_account_name = if (maybe_b) |rec| rec.account_name else null; + return optionalBytesEqual(a_account_name, b_account_name); +} + +fn replaceOptionalOwnedString( + allocator: std.mem.Allocator, + target: *?[]u8, + value: ?[]const u8, +) !bool { + if (optionalBytesEqual(target.*, value)) return false; + const replacement = if (value) |text| try allocator.dupe(u8, text) else null; + if (target.*) |existing| allocator.free(existing); + target.* = replacement; + return true; +} + +fn applySwitchLiveUsageDeltaToLatest( + allocator: std.mem.Allocator, + latest: *registry.Registry, + base_rec: ?*const registry.AccountRecord, + refreshed_rec: *const registry.AccountRecord, +) !bool { + if (switchLiveUsageFieldsEqual(base_rec, refreshed_rec)) return false; + + const latest_idx = findAccountIndexByAccountKeyConst(latest, refreshed_rec.account_key) orelse return false; + const latest_rec = &latest.accounts.items[latest_idx]; + if (!switchLiveUsageFieldsEqual(base_rec, latest_rec)) return false; + + if (refreshed_rec.last_usage) |snapshot| { + const cloned_snapshot = try registry.cloneRateLimitSnapshot(allocator, snapshot); + registry.updateUsage(allocator, latest, refreshed_rec.account_key, cloned_snapshot); + latest.accounts.items[latest_idx].last_usage_at = refreshed_rec.last_usage_at; + } + if (refreshed_rec.last_local_rollout) |signature| { + try registry.setAccountLastLocalRollout( + allocator, + &latest.accounts.items[latest_idx], + signature.path, + signature.event_timestamp_ms, + ); + } + return true; +} + +fn applySwitchLiveAccountNameDeltaToLatest( + allocator: std.mem.Allocator, + latest: *registry.Registry, + base_rec: ?*const registry.AccountRecord, + refreshed_rec: *const registry.AccountRecord, +) !bool { + if (switchLiveAccountNameEqual(base_rec, refreshed_rec)) return false; + + const latest_idx = findAccountIndexByAccountKeyConst(latest, refreshed_rec.account_key) orelse return false; + const latest_rec = &latest.accounts.items[latest_idx]; + if (!switchLiveAccountNameEqual(base_rec, latest_rec)) return false; + + return try replaceOptionalOwnedString(allocator, &latest_rec.account_name, refreshed_rec.account_name); +} + +fn allocEmptySwitchUsageOverrides(allocator: std.mem.Allocator, len: usize) ![]?[]const u8 { + const usage_overrides = try allocator.alloc(?[]const u8, len); + for (usage_overrides) |*usage_override| usage_override.* = null; + return usage_overrides; +} + +fn mapSwitchUsageOverridesToLatest( + allocator: std.mem.Allocator, + latest: *const registry.Registry, + refreshed: *const registry.Registry, + usage_overrides: []const ?[]const u8, +) ![]?[]const u8 { + const mapped = try allocEmptySwitchUsageOverrides(allocator, latest.accounts.items.len); + errdefer { + for (mapped) |value| { + if (value) |text| allocator.free(text); + } + allocator.free(mapped); + } + + for (refreshed.accounts.items, 0..) |rec, refreshed_idx| { + const usage_override = usage_overrides[refreshed_idx] orelse continue; + const latest_idx = findAccountIndexByAccountKeyConst(latest, rec.account_key) orelse continue; + mapped[latest_idx] = try allocator.dupe(u8, usage_override); + } + return mapped; +} + +fn mergeSwitchLiveRefreshIntoLatest( + allocator: std.mem.Allocator, + latest: *registry.Registry, + base: *const registry.Registry, + refreshed: *const registry.Registry, +) !bool { + var changed = false; + for (refreshed.accounts.items) |*refreshed_rec| { + const base_idx = findAccountIndexByAccountKeyConst(base, refreshed_rec.account_key); + const base_rec = if (base_idx) |idx| &base.accounts.items[idx] else null; + if (try applySwitchLiveUsageDeltaToLatest(allocator, latest, base_rec, refreshed_rec)) { + changed = true; + } + if (try applySwitchLiveAccountNameDeltaToLatest(allocator, latest, base_rec, refreshed_rec)) { + changed = true; + } + } + return changed; +} + +fn takeOwnedSwitchSelectionDisplay( + allocator: std.mem.Allocator, + reg: registry.Registry, + usage_state: *ForegroundUsageRefreshState, +) cli.OwnedSwitchSelectionDisplay { + const usage_overrides = usage_state.usage_overrides; + allocator.free(usage_state.outcomes); + usage_state.* = undefined; + return .{ + .reg = reg, + .usage_overrides = usage_overrides, + }; +} + +fn loadSwitchSelectionDisplay( + allocator: std.mem.Allocator, + codex_home: []const u8, + api_mode: cli.ApiMode, +) !SwitchLoadedDisplay { + if (apiModeUsesStoredDataOnly(api_mode)) { + var latest = try registry.loadRegistry(allocator, codex_home); + errdefer latest.deinit(allocator); + if (try registry.syncActiveAccountFromAuth(allocator, codex_home, &latest)) { + try registry.saveRegistry(allocator, codex_home, &latest); + } + return .{ + .display = .{ + .reg = latest, + .usage_overrides = try allocEmptySwitchUsageOverrides(allocator, latest.accounts.items.len), + }, + .policy = switchLiveRefreshPolicy(&latest, api_mode), + }; + } + + var base = try registry.loadRegistry(allocator, codex_home); + defer base.deinit(allocator); + + var refreshed = try registry.loadRegistry(allocator, codex_home); + errdefer refreshed.deinit(allocator); + _ = try registry.syncActiveAccountFromAuth(allocator, codex_home, &refreshed); + const initial_policy = switchLiveRefreshPolicy(&refreshed, api_mode); try ensureForegroundNodeAvailableWithApiEnabled( allocator, codex_home, - ®, + &refreshed, .switch_account, - usage_api_enabled, - account_api_enabled, + initial_policy.usage_api_enabled, + initial_policy.account_api_enabled, ); - var usage_state = try refreshForegroundUsageForDisplayWithApiFetcherWithPoolInitAndDebugUsingApiEnabled( + var usage_state = try refreshForegroundUsageForDisplayWithApiFetcherWithPoolInitAndDebugUsingApiEnabledAndPersist( allocator, codex_home, - ®, + &refreshed, usage_api.fetchUsageForAuthPathDetailed, initForegroundUsagePool, null, - usage_api_enabled, + initial_policy.usage_api_enabled, + false, ); - defer usage_state.deinit(allocator); - try maybeRefreshForegroundAccountNamesWithAccountApiEnabled( + errdefer usage_state.deinit(allocator); + _ = try maybeRefreshForegroundAccountNamesWithAccountApiEnabledAndPersist( allocator, codex_home, - ®, + &refreshed, .switch_account, defaultAccountFetcher, - account_api_enabled, + initial_policy.account_api_enabled, + false, ); - const selected_account_key = try cli.selectAccountWithUsageOverrides(allocator, ®, usage_state.usage_overrides); - if (selected_account_key == null) return; + var latest = try registry.loadRegistry(allocator, codex_home); + errdefer latest.deinit(allocator); + var latest_changed = try registry.syncActiveAccountFromAuth(allocator, codex_home, &latest); - try registry.activateAccountByKey(allocator, codex_home, ®, selected_account_key.?); - try registry.saveRegistry(allocator, codex_home, ®); + if (try mergeSwitchLiveRefreshIntoLatest(allocator, &latest, &base, &refreshed)) { + latest_changed = true; + } + + if (latest_changed) try registry.saveRegistry(allocator, codex_home, &latest); + const mapped_usage_overrides = try mapSwitchUsageOverridesToLatest( + allocator, + &latest, + &refreshed, + usage_state.usage_overrides, + ); + usage_state.deinit(allocator); + refreshed.deinit(allocator); + + return .{ + .display = .{ + .reg = latest, + .usage_overrides = mapped_usage_overrides, + }, + .policy = switchLiveRefreshPolicy(&latest, api_mode), + }; +} + +fn runSwitchLiveRefreshRound(runtime: *SwitchLiveRuntime) void { + const io = runtime.io_impl.io(); + const started_ms = time_compat.milliTimestamp(); + const loaded = loadSwitchSelectionDisplay(runtime.allocator, runtime.codex_home, runtime.api_mode) catch |err| { + const finished_ms = time_compat.milliTimestamp(); + const error_name = runtime.allocator.dupe(u8, @errorName(err)) catch null; + + runtime.mutex.lockUncancelable(io); + defer runtime.mutex.unlock(io); + if (runtime.last_refresh_error_name) |name| runtime.allocator.free(name); + runtime.last_refresh_error_name = error_name; + runtime.last_refresh_finished_at_ms = finished_ms; + runtime.last_refresh_duration_ms = finished_ms - (runtime.last_refresh_started_at_ms orelse started_ms); + runtime.next_refresh_not_before_ms = finished_ms + runtime.refresh_interval_ms; + runtime.in_flight = false; + return; + }; + + const finished_ms = time_compat.milliTimestamp(); + runtime.mutex.lockUncancelable(io); + defer runtime.mutex.unlock(io); + + if (runtime.updated_display) |*display| display.deinit(runtime.allocator); + runtime.updated_display = loaded.display; + runtime.refresh_interval_ms = loaded.policy.interval_ms; + runtime.mode_label = loaded.policy.label; + if (runtime.last_refresh_error_name) |name| runtime.allocator.free(name); + runtime.last_refresh_error_name = null; + runtime.last_refresh_finished_at_ms = finished_ms; + runtime.last_refresh_duration_ms = finished_ms - (runtime.last_refresh_started_at_ms orelse started_ms); + runtime.next_refresh_not_before_ms = finished_ms + runtime.refresh_interval_ms; + runtime.in_flight = false; +} + +fn switchLiveRuntimeMaybeStartRefresh(context: *anyopaque) !void { + const runtime: *SwitchLiveRuntime = @ptrCast(@alignCast(context)); + runtime.maybeStartRefresh(); +} + +fn switchLiveRuntimeMaybeTakeUpdatedDisplay(context: *anyopaque) !?cli.OwnedSwitchSelectionDisplay { + const runtime: *SwitchLiveRuntime = @ptrCast(@alignCast(context)); + return runtime.maybeTakeUpdatedDisplay(); +} + +fn switchLiveRuntimeBuildStatusLine( + context: *anyopaque, + allocator: std.mem.Allocator, + display: cli.SwitchSelectionDisplay, +) ![]u8 { + const runtime: *SwitchLiveRuntime = @ptrCast(@alignCast(context)); + return runtime.buildStatusLine(allocator, display); } pub fn resolveSwitchQueryLocally( From 4b7f77ef5aa32efcdb443f8251bf23c06cdf03c6 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Mon, 20 Apr 2026 03:08:18 +0800 Subject: [PATCH 02/11] fix: simplify switch tui rendering --- src/cli.zig | 28 ++++++++++++++++------------ src/main.zig | 45 ++------------------------------------------- 2 files changed, 18 insertions(+), 55 deletions(-) diff --git a/src/cli.zig b/src/cli.zig index 883bd65..8f0bd4b 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -63,6 +63,18 @@ fn writeTuiResetFrameTo(out: *std.Io.Writer) !void { try out.writeAll("\x1b[H\x1b[J"); } +fn writeSwitchTuiFooter(out: *std.Io.Writer, use_color: bool) !void { + if (use_color) try out.writeAll(ansi.dim); + try out.writeAll("Keys: ↑/↓ or j/k, Enter select, Esc or q quit\n"); + if (use_color) try out.writeAll(ansi.reset); +} + +fn writeRemoveTuiFooter(out: *std.Io.Writer, use_color: bool) !void { + if (use_color) try out.writeAll(ansi.dim); + try out.writeAll("Keys: ↑/↓ or j/k move, Space toggle, Enter delete, Esc or q quit\n"); + if (use_color) try out.writeAll(ansi.reset); +} + const TuiSession = struct { input: std.Io.File, output: std.Io.File, @@ -1925,9 +1937,7 @@ fn selectInteractiveFromIndices( try out.writeAll("Select account to activate:\n\n"); try renderSwitchList(out, reg, rows.items, idx_width, widths, idx, use_color); try out.writeAll("\n"); - if (use_color) try out.writeAll(ansi.dim); - try out.writeAll("Keys: ↑/↓ or j/k, Enter select, 1-9 type, Backspace edit, Esc or q quit\n"); - if (use_color) try out.writeAll(ansi.reset); + try writeSwitchTuiFooter(out, use_color); try out.flush(); var b: [8]u8 = undefined; @@ -2107,9 +2117,7 @@ fn selectInteractive( try out.writeAll("Select account to activate:\n\n"); try renderSwitchList(out, reg, rows.items, idx_width, widths, idx, use_color); try out.writeAll("\n"); - if (use_color) try out.writeAll(ansi.dim); - try out.writeAll("Keys: ↑/↓ or j/k, Enter select, 1-9 type, Backspace edit, Esc or q quit\n"); - if (use_color) try out.writeAll(ansi.reset); + try writeSwitchTuiFooter(out, use_color); try out.flush(); var b: [8]u8 = undefined; @@ -2218,9 +2226,7 @@ fn selectRemoveInteractive( try out.writeAll("Select accounts to delete:\n\n"); try renderRemoveList(out, reg, rows.items, idx_width, widths, idx, checked, use_color); try out.writeAll("\n"); - if (use_color) try out.writeAll(ansi.dim); - try out.writeAll("Keys: ↑/↓ or j/k move, Space toggle, Enter delete, 1-9 type, Backspace edit, Esc or q quit\n"); - if (use_color) try out.writeAll(ansi.reset); + try writeRemoveTuiFooter(out, use_color); try out.flush(); var b: [8]u8 = undefined; @@ -2331,9 +2337,7 @@ fn renderSwitchScreen( try out.writeAll("\n"); if (use_color) try out.writeAll(ansi.reset); } - if (use_color) try out.writeAll(ansi.dim); - try out.writeAll("Keys: ↑/↓ or j/k, Enter select, 1-9 type, Backspace edit, Esc or q quit\n"); - if (use_color) try out.writeAll(ansi.reset); + try writeSwitchTuiFooter(out, use_color); } fn renderSwitchList( diff --git a/src/main.zig b/src/main.zig index 8a9c377..45183a4 100644 --- a/src/main.zig +++ b/src/main.zig @@ -10,7 +10,6 @@ const auth = @import("auth.zig"); const auto = @import("auto.zig"); const format = @import("format.zig"); const io_util = @import("io_util.zig"); -const timefmt = @import("timefmt.zig"); const usage_api = @import("usage_api.zig"); const skip_service_reconcile_env = "CODEX_AUTH_SKIP_SERVICE_RECONCILE"; @@ -1861,9 +1860,9 @@ const SwitchLiveRuntime = struct { } fn buildStatusLine(self: *@This(), allocator: std.mem.Allocator, display: cli.SwitchSelectionDisplay) ![]u8 { + _ = display; const io = self.io_impl.io(); const now_ms = nowMilliseconds(); - const now_s = nowSeconds(); var in_flight = false; var next_refresh_not_before_ms: i64 = now_ms; @@ -1902,50 +1901,10 @@ const SwitchLiveRuntime = struct { try allocator.dupe(u8, ""); defer allocator.free(error_suffix); - const active_account_key = trackedActiveAccountKey(display.reg); - if (active_account_key == null) { - return std.fmt.allocPrint( - allocator, - "Active: - | 5H - | Weekly - | Last - | Mode: {s} | Refresh: {s} | Last round: {s}{s}", - .{ - mode_label, - refresh_state, - round_state, - error_suffix, - }, - ); - } - - const active_idx = registry.findAccountIndexByAccountKey(display.reg, active_account_key.?) orelse { - return std.fmt.allocPrint( - allocator, - "Active: - | 5H - | Weekly - | Last - | Mode: {s} | Refresh: {s} | Last round: {s}{s}", - .{ - mode_label, - refresh_state, - round_state, - error_suffix, - }, - ); - }; - const rec = &display.reg.accounts.items[active_idx]; - const active_label = try display_rows.buildPreferredAccountLabelAlloc(allocator, rec, rec.email); - defer allocator.free(active_label); - const remaining_5h = try formatRemainingPercentAlloc(allocator, registry.resolveRateWindow(rec.last_usage, 300, true)); - defer allocator.free(remaining_5h); - const remaining_weekly = try formatRemainingPercentAlloc(allocator, registry.resolveRateWindow(rec.last_usage, 10080, false)); - defer allocator.free(remaining_weekly); - const last_activity = try timefmt.formatRelativeTimeOrDashAlloc(allocator, rec.last_used_at, now_s); - defer allocator.free(last_activity); - return std.fmt.allocPrint( allocator, - "Active: {s} | 5H {s} | Weekly {s} | Last {s} | Mode: {s} | Refresh: {s} | Last round: {s}{s}", + "Live refresh: {s} | Next: {s} | Last round: {s}{s}", .{ - active_label, - remaining_5h, - remaining_weekly, - last_activity, mode_label, refresh_state, round_state, From 22ddf81b13769892d3b2d2ae1fe210ad8c05269d Mon Sep 17 00:00:00 2001 From: Loongphy Date: Mon, 20 Apr 2026 03:14:34 +0800 Subject: [PATCH 03/11] fix: restore non-tty switch selection for tests --- src/cli.zig | 34 +++++++++++++++++++++++++--------- src/tests/e2e_cli_test.zig | 7 ------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/cli.zig b/src/cli.zig index 8f0bd4b..3d8cd4e 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -1533,10 +1533,14 @@ pub fn selectAccountWithUsageOverrides( reg: *registry.Registry, usage_overrides: ?[]const ?[]const u8, ) !?[]const u8 { - return if (comptime builtin.os.tag == .windows) - selectWithNumbers(allocator, reg, usage_overrides) - else - try selectInteractive(allocator, reg, usage_overrides); + if (shouldUseNumberedSwitchSelector( + comptime builtin.os.tag == .windows, + std.Io.File.stdin().isTty(app_runtime.io()) catch false, + std.Io.File.stdout().isTty(app_runtime.io()) catch false, + )) { + return selectWithNumbers(allocator, reg, usage_overrides); + } + return try selectInteractive(allocator, reg, usage_overrides); } pub const SwitchSelectionDisplay = struct { @@ -1585,7 +1589,11 @@ pub fn selectAccountWithLiveUpdates( defer current_display.deinit(allocator); if (current_display.reg.accounts.items.len == 0) return null; - if (comptime builtin.os.tag == .windows) { + if (shouldUseNumberedSwitchSelector( + comptime builtin.os.tag == .windows, + std.Io.File.stdin().isTty(app_runtime.io()) catch false, + std.Io.File.stdout().isTty(app_runtime.io()) catch false, + )) { const selected_account_key = try selectWithNumbers(allocator, ¤t_display.reg, current_display.usage_overrides); return try dupeOptionalAccountKey(allocator, selected_account_key); } @@ -1750,10 +1758,18 @@ pub fn selectAccountFromIndicesWithUsageOverrides( ) !?[]const u8 { if (indices.len == 0) return null; if (indices.len == 1) return reg.accounts.items[indices[0]].account_key; - return if (comptime builtin.os.tag == .windows) - selectWithNumbersFromIndices(allocator, reg, indices, usage_overrides) - else - try selectInteractiveFromIndices(allocator, reg, indices, usage_overrides); + if (shouldUseNumberedSwitchSelector( + comptime builtin.os.tag == .windows, + std.Io.File.stdin().isTty(app_runtime.io()) catch false, + std.Io.File.stdout().isTty(app_runtime.io()) catch false, + )) { + return selectWithNumbersFromIndices(allocator, reg, indices, usage_overrides); + } + return try selectInteractiveFromIndices(allocator, reg, indices, usage_overrides); +} + +pub fn shouldUseNumberedSwitchSelector(is_windows: bool, stdin_is_tty: bool, stdout_is_tty: bool) bool { + return is_windows or !stdin_is_tty or !stdout_is_tty; } pub fn selectAccountsToRemove(allocator: std.mem.Allocator, reg: *registry.Registry) !?[]usize { diff --git a/src/tests/e2e_cli_test.zig b/src/tests/e2e_cli_test.zig index 39f85e3..2891b01 100644 --- a/src/tests/e2e_cli_test.zig +++ b/src/tests/e2e_cli_test.zig @@ -69,13 +69,6 @@ fn buildCliBinary(allocator: std.mem.Allocator, project_root: []const u8) !void if (cli_build_ready) return; - const exe_path = try builtCliPathAlloc(allocator, project_root); - defer allocator.free(exe_path); - if (fs.accessAbsolute(exe_path, .{})) |_| { - cli_build_ready = true; - return; - } else |_| {} - var env_map = try getEnvMap(allocator); defer env_map.deinit(); const global_cache_dir = if (env_map.get("ZIG_GLOBAL_CACHE_DIR")) |dir| From dfc6375659eba1d8fed87367b62f193ffa96638d Mon Sep 17 00:00:00 2001 From: Loongphy Date: Mon, 20 Apr 2026 03:23:26 +0800 Subject: [PATCH 04/11] fix: refine switch tui behavior --- src/cli.zig | 82 ++++++++++++++++++++++++++++++++++++++++++++++++---- src/main.zig | 23 ++++----------- 2 files changed, 82 insertions(+), 23 deletions(-) diff --git a/src/cli.zig b/src/cli.zig index 3d8cd4e..d4c1c33 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -65,13 +65,13 @@ fn writeTuiResetFrameTo(out: *std.Io.Writer) !void { fn writeSwitchTuiFooter(out: *std.Io.Writer, use_color: bool) !void { if (use_color) try out.writeAll(ansi.dim); - try out.writeAll("Keys: ↑/↓ or j/k, Enter select, Esc or q quit\n"); + try out.writeAll("Keys: ↑/↓ or j/k, 1-9 type, Backspace edit, Enter select, Esc or q quit\n"); if (use_color) try out.writeAll(ansi.reset); } fn writeRemoveTuiFooter(out: *std.Io.Writer, use_color: bool) !void { if (use_color) try out.writeAll(ansi.dim); - try out.writeAll("Keys: ↑/↓ or j/k move, Space toggle, Enter delete, Esc or q quit\n"); + try out.writeAll("Keys: ↑/↓ or j/k move, Space toggle, 1-9 type, Backspace edit, Enter delete, Esc or q quit\n"); if (use_color) try out.writeAll(ansi.reset); } @@ -1623,6 +1623,7 @@ pub fn selectAccountWithLiveUpdates( const borrowed = current_display.borrowed(); var rows = try buildSwitchRowsWithUsageOverrides(allocator, borrowed.reg, borrowed.usage_overrides); defer rows.deinit(allocator); + try filterErroredRowsFromSelectableIndices(allocator, &rows); if (rows.selectable_row_indices.len == 0) return null; var selected_idx = if (selected_account_key) |key| @@ -1868,6 +1869,8 @@ fn selectWithNumbers( if (reg.accounts.items.len == 0) return null; var rows = try buildSwitchRowsWithUsageOverrides(allocator, reg, usage_overrides); defer rows.deinit(allocator); + try filterErroredRowsFromSelectableIndices(allocator, &rows); + if (rows.selectable_row_indices.len == 0) return null; const use_color = colorEnabled(); const active_idx = activeSelectableIndex(&rows); const idx_width = @max(@as(usize, 2), indexWidth(rows.selectable_row_indices.len)); @@ -1904,6 +1907,8 @@ fn selectWithNumbersFromIndices( var rows = try buildSwitchRowsFromIndicesWithUsageOverrides(allocator, reg, indices, usage_overrides); defer rows.deinit(allocator); + try filterErroredRowsFromSelectableIndices(allocator, &rows); + if (rows.selectable_row_indices.len == 0) return null; const use_color = colorEnabled(); const active_idx = activeSelectableIndex(&rows); const idx_width = @max(@as(usize, 2), indexWidth(rows.selectable_row_indices.len)); @@ -1936,6 +1941,8 @@ fn selectInteractiveFromIndices( if (indices.len == 0) return null; var rows = try buildSwitchRowsFromIndicesWithUsageOverrides(allocator, reg, indices, usage_overrides); defer rows.deinit(allocator); + try filterErroredRowsFromSelectableIndices(allocator, &rows); + if (rows.selectable_row_indices.len == 0) return null; var tui = try TuiSession.init(); defer tui.deinit(); @@ -2116,6 +2123,8 @@ fn selectInteractive( if (reg.accounts.items.len == 0) return null; var rows = try buildSwitchRowsWithUsageOverrides(allocator, reg, usage_overrides); defer rows.deinit(allocator); + try filterErroredRowsFromSelectableIndices(allocator, &rows); + if (rows.selectable_row_indices.len == 0) return null; var tui = try TuiSession.init(); defer tui.deinit(); @@ -2397,7 +2406,8 @@ fn renderSwitchList( continue; } - const is_selected = selected != null and selected.? == selectable_counter; + const is_selectable = !row.has_error; + const is_selected = is_selectable and selected != null and selected.? == selectable_counter; const is_active = row.is_active; if (use_color) { if (row.has_error) { @@ -2415,7 +2425,11 @@ fn renderSwitchList( } } try out.writeAll(if (is_selected) "> " else " "); - try writeIndexPadded(out, selectable_counter + 1, idx_width); + if (is_selectable) { + try writeIndexPadded(out, selectable_counter + 1, idx_width); + } else { + try writeRepeat(out, ' ', idx_width); + } try out.writeAll(" "); const indent: usize = @as(usize, row.depth) * 2; const indent_to_print: usize = @min(indent, widths.email); @@ -2434,7 +2448,7 @@ fn renderSwitchList( } try out.writeAll("\n"); if (use_color) try out.writeAll(ansi.reset); - selectable_counter += 1; + if (is_selectable) selectable_counter += 1; } } @@ -2608,6 +2622,24 @@ const SwitchRows = struct { } }; +fn filterErroredRowsFromSelectableIndices(allocator: std.mem.Allocator, rows: *SwitchRows) !void { + var selectable_count: usize = 0; + for (rows.selectable_row_indices) |row_idx| { + if (!rows.items[row_idx].has_error) selectable_count += 1; + } + + const filtered = try allocator.alloc(usize, selectable_count); + var next_idx: usize = 0; + for (rows.selectable_row_indices) |row_idx| { + if (rows.items[row_idx].has_error) continue; + filtered[next_idx] = row_idx; + next_idx += 1; + } + + allocator.free(rows.selectable_row_indices); + rows.selectable_row_indices = filtered; +} + fn usageOverrideForAccount( usage_overrides: ?[]const ?[]const u8, account_idx: usize, @@ -2990,6 +3022,46 @@ test "Scenario: Given usage overrides when rendering switch list then failed row try std.testing.expect(std.mem.count(u8, output, "401") >= 2); } +test "Scenario: Given usage overrides when selecting switch accounts then errored rows are skipped" { + const gpa = std.testing.allocator; + var reg = makeTestRegistry(); + defer reg.deinit(gpa); + + try appendTestAccount(gpa, ®, "user-1::acc-1", "healthy@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-2", "failed@example.com", "", .free); + + const usage_overrides = [_]?[]const u8{ null, "401" }; + var rows = try buildSwitchRowsWithUsageOverrides(gpa, ®, &usage_overrides); + defer rows.deinit(gpa); + try filterErroredRowsFromSelectableIndices(gpa, &rows); + + try std.testing.expectEqual(@as(usize, 1), rows.selectable_row_indices.len); + try std.testing.expect(std.mem.eql(u8, accountIdForSelectable(&rows, ®, 0), "user-1::acc-1")); +} + +test "Scenario: Given usage overrides when rendering switch list then errored rows do not show selection numbers" { + const gpa = std.testing.allocator; + var reg = makeTestRegistry(); + defer reg.deinit(gpa); + + try appendTestAccount(gpa, ®, "user-1::acc-1", "healthy@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-2", "failed@example.com", "", .free); + + const usage_overrides = [_]?[]const u8{ null, "401" }; + var rows = try buildSwitchRowsWithUsageOverrides(gpa, ®, &usage_overrides); + defer rows.deinit(gpa); + + var buffer: [2048]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buffer); + const idx_width = @max(@as(usize, 2), indexWidth(rows.selectable_row_indices.len)); + try renderSwitchList(&writer, ®, rows.items, idx_width, rows.widths, null, false); + + const output = writer.buffered(); + try std.testing.expect(std.mem.indexOf(u8, output, "01 healthy@example.com") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "02 failed@example.com") == null); + try std.testing.expect(std.mem.indexOf(u8, output, "failed@example.com") != null); +} + test "Scenario: Given usage overrides when rendering remove list then failed rows show response status in both usage columns" { const gpa = std.testing.allocator; var reg = makeTestRegistry(); diff --git a/src/main.zig b/src/main.zig index 45183a4..3b7c782 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1866,14 +1866,12 @@ const SwitchLiveRuntime = struct { var in_flight = false; var next_refresh_not_before_ms: i64 = now_ms; - var last_round_duration_ms: ?i64 = null; var mode_label: []const u8 = "stored"; var refresh_error_name: ?[]u8 = null; self.mutex.lockUncancelable(io); in_flight = self.in_flight; next_refresh_not_before_ms = self.next_refresh_not_before_ms; - last_round_duration_ms = self.last_refresh_duration_ms; mode_label = self.mode_label; if (self.last_refresh_error_name) |error_name| { refresh_error_name = try allocator.dupe(u8, error_name); @@ -1882,19 +1880,13 @@ const SwitchLiveRuntime = struct { defer if (refresh_error_name) |value| allocator.free(value); const refresh_state = if (in_flight) - try allocator.dupe(u8, "running") + try allocator.dupe(u8, "Refresh running") else if (next_refresh_not_before_ms <= now_ms) - try allocator.dupe(u8, "due") + try allocator.dupe(u8, "Refresh due") else - try std.fmt.allocPrint(allocator, "in {d}s", .{@divFloor((next_refresh_not_before_ms - now_ms) + 999, 1000)}); + try std.fmt.allocPrint(allocator, "Refresh in {d}s", .{@divFloor((next_refresh_not_before_ms - now_ms) + 999, 1000)}); defer allocator.free(refresh_state); - const round_state = if (last_round_duration_ms) |duration_ms| - try std.fmt.allocPrint(allocator, "{d}s", .{@divFloor(duration_ms + 999, 1000)}) - else - try allocator.dupe(u8, "-"); - defer allocator.free(round_state); - const error_suffix = if (refresh_error_name) |value| try std.fmt.allocPrint(allocator, " | Error: {s}", .{value}) else @@ -1903,13 +1895,8 @@ const SwitchLiveRuntime = struct { return std.fmt.allocPrint( allocator, - "Live refresh: {s} | Next: {s} | Last round: {s}{s}", - .{ - mode_label, - refresh_state, - round_state, - error_suffix, - }, + "Live refresh: {s} | {s}{s}", + .{ mode_label, refresh_state, error_suffix }, ); } }; From b67836902904e3f06a0557b2e1c542755eb5e48b Mon Sep 17 00:00:00 2001 From: Loongphy Date: Mon, 20 Apr 2026 03:33:19 +0800 Subject: [PATCH 05/11] fix: restore windows preview builds --- src/cli.zig | 73 +++++++++++++++++++++++++++++++++------------------- src/main.zig | 28 ++++++++++---------- 2 files changed, 61 insertions(+), 40 deletions(-) diff --git a/src/cli.zig b/src/cli.zig index d4c1c33..ab1bd50 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -24,7 +24,8 @@ const ansi = struct { const bold = "\x1b[1m"; }; -const tui_poll_error_mask: i16 = std.posix.POLL.ERR | std.posix.POLL.HUP | std.posix.POLL.NVAL; +const tui_poll_input_mask: i16 = if (builtin.os.tag == .windows) 0 else std.posix.POLL.IN; +const tui_poll_error_mask: i16 = if (builtin.os.tag == .windows) 0 else std.posix.POLL.ERR | std.posix.POLL.HUP | std.posix.POLL.NVAL; const tui_escape_sequence_timeout_ms: i32 = 100; const TuiNavigation = enum { @@ -50,6 +51,28 @@ const TuiEscapeReadResult = struct { buffered_bytes_consumed: usize, }; +const TuiPollResult = enum { + ready, + timeout, + closed, +}; + +fn pollTuiInput(file: std.Io.File, timeout_ms: i32, poll_error_mask: i16) !TuiPollResult { + if (comptime builtin.os.tag == .windows) { + return .closed; + } else { + var fds = [_]std.posix.pollfd{.{ + .fd = file.handle, + .events = tui_poll_input_mask, + .revents = 0, + }}; + const ready = try std.posix.poll(&fds, timeout_ms); + if (ready == 0) return .timeout; + if ((fds[0].revents & poll_error_mask) != 0) return .closed; + return .ready; + } +} + fn writeTuiEnterTo(out: *std.Io.Writer) !void { try out.writeAll("\x1b[?1049h\x1b[?25l"); try out.writeAll("\x1b[H\x1b[J"); @@ -75,10 +98,12 @@ fn writeRemoveTuiFooter(out: *std.Io.Writer, use_color: bool) !void { if (use_color) try out.writeAll(ansi.reset); } +const TuiSavedState = if (builtin.os.tag == .windows) void else std.posix.termios; + const TuiSession = struct { input: std.Io.File, output: std.Io.File, - saved_termios: std.posix.termios, + saved_termios: TuiSavedState = if (builtin.os.tag == .windows) {} else undefined, writer_buffer: [4096]u8 = undefined, writer: std.Io.File.Writer = undefined, @@ -89,6 +114,10 @@ const TuiSession = struct { return error.TuiRequiresTty; } + if (comptime builtin.os.tag == .windows) { + return error.TuiRequiresTty; + } + const saved_termios = try std.posix.tcgetattr(input.handle); var raw = saved_termios; raw.lflag.ICANON = false; @@ -112,7 +141,9 @@ const TuiSession = struct { const writer = self.out(); writeTuiExitTo(writer) catch {}; writer.flush() catch {}; - std.posix.tcsetattr(self.input.handle, .FLUSH, self.saved_termios) catch {}; + if (comptime builtin.os.tag != .windows) { + std.posix.tcsetattr(self.input.handle, .FLUSH, self.saved_termios) catch {}; + } self.* = undefined; } @@ -211,23 +242,16 @@ fn readTuiEscapeAction( }; } - var fds = [_]std.posix.pollfd{.{ - .fd = tty.handle, - .events = std.posix.POLL.IN, - .revents = 0, - }}; - const ready = try std.posix.poll(&fds, timeout_ms); - if (ready == 0) { - return .{ + switch (try pollTuiInput(tty, timeout_ms, poll_error_mask)) { + .timeout => return .{ .action = if (seq_len == 0) .quit else .ignore, .buffered_bytes_consumed = buffered_bytes_consumed, - }; - } - if ((fds[0].revents & poll_error_mask) != 0) { - return .{ + }, + .closed => return .{ .action = .quit, .buffered_bytes_consumed = buffered_bytes_consumed, - }; + }, + .ready => {}, } const read_n = try readFileOnce(tty, seq[seq_len .. seq_len + 1]); @@ -1648,17 +1672,14 @@ pub fn selectAccountWithLiveUpdates( ); try out.flush(); - var fds = [_]std.posix.pollfd{.{ - .fd = tui.input.handle, - .events = std.posix.POLL.IN, - .revents = 0, - }}; - const ready = try std.posix.poll(&fds, ui_tick_ms); - if (ready == 0) { - try controller.maybe_start_refresh(controller.context); - continue; + switch (try pollTuiInput(tui.input, ui_tick_ms, tui_poll_error_mask)) { + .timeout => { + try controller.maybe_start_refresh(controller.context); + continue; + }, + .closed => return null, + .ready => {}, } - if ((fds[0].revents & tui_poll_error_mask) != 0) return null; var b: [8]u8 = undefined; const n = try tui.read(&b); diff --git a/src/main.zig b/src/main.zig index 3b7c782..13fc262 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1682,12 +1682,12 @@ fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. ®, matches.items, null, - ) catch |err| switch (err) { - error.TuiRequiresTty => { + ) catch |err| { + if (err == error.TuiRequiresTty) { try cli.printSwitchRequiresTtyError(); return error.SwitchSelectionRequiresTty; - }, - else => return err, + } + return err; }, }; if (selected_account_key == null) return; @@ -1715,12 +1715,12 @@ fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. const transferred_display = initial_display.?; initial_display = null; - break :blk cli.selectAccountWithLiveUpdates(live_allocator, transferred_display, controller) catch |err| switch (err) { - error.TuiRequiresTty => { + break :blk cli.selectAccountWithLiveUpdates(live_allocator, transferred_display, controller) catch |err| { + if (err == error.TuiRequiresTty) { try cli.printSwitchRequiresTtyError(); return error.SwitchSelectionRequiresTty; - }, - else => return err, + } + return err; }; }; defer if (selected_account_key) |account_key| live_allocator.free(@constCast(account_key)); @@ -2493,16 +2493,16 @@ fn handleRemove(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. allocator, ®, usage_overrides, - ) catch |err| switch (err) { - error.InvalidRemoveSelectionInput => { + ) catch |err| { + if (err == error.InvalidRemoveSelectionInput) { try cli.printInvalidRemoveSelectionError(); return error.InvalidRemoveSelectionInput; - }, - error.TuiRequiresTty => { + } + if (err == error.TuiRequiresTty) { try cli.printRemoveRequiresTtyError(); return error.RemoveSelectionRequiresTty; - }, - else => return err, + } + return err; }; } if (selected == null) return; From 299b76a4e2d741999e70998beac40e6343406ea6 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Mon, 20 Apr 2026 15:18:12 +0800 Subject: [PATCH 06/11] feat: add native windows tui support --- scripts/windows/tui-smoke.ps1 | 228 ++++++++++++ src/cli.zig | 649 +++++++++++++++++++++++++++++++--- src/tests/cli_bdd_test.zig | 14 +- 3 files changed, 833 insertions(+), 58 deletions(-) create mode 100644 scripts/windows/tui-smoke.ps1 diff --git a/scripts/windows/tui-smoke.ps1 b/scripts/windows/tui-smoke.ps1 new file mode 100644 index 0000000..e3be47a --- /dev/null +++ b/scripts/windows/tui-smoke.ps1 @@ -0,0 +1,228 @@ +param( + [Parameter(Mandatory = $true)] + [string]$ExePath, + [ValidateSet('switch', 'remove', 'both')] + [string]$Scenario = 'both', + [string]$TestRoot = $env:TEMP, + [int]$TimeoutMs = 10000, + [string]$OutputJsonPath +) + +$ErrorActionPreference = 'Stop' + +function Test-WslHostedPath([string]$Path) { + return $Path.StartsWith('\\wsl$', [System.StringComparison]::OrdinalIgnoreCase) -or + $Path.StartsWith('\\wsl.localhost\', [System.StringComparison]::OrdinalIgnoreCase) +} + +function Assert-WindowsLocalPath([string]$Label, [string]$Path) { + if ([string]::IsNullOrWhiteSpace($Path)) { + throw "$Label must not be empty." + } + if (Test-WslHostedPath $Path) { + throw "$Label must be Windows-local. Copy the artifact into `$env:TEMP or another local directory first." + } +} + +function Get-AccountSnapshotPath([string]$CodexHome, [string]$AccountKey) { + $bytes = [Text.Encoding]::UTF8.GetBytes($AccountKey) + $fileKey = [Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_') + return Join-Path (Join-Path $CodexHome 'accounts') ($fileKey + '.auth.json') +} + +function New-SmokeLayout([string]$BaseDir) { + $codexHome = Join-Path $BaseDir 'codex-home' + $accountsDir = Join-Path $codexHome 'accounts' + $null = New-Item -ItemType Directory -Force -Path $accountsDir + + $firstKey = 'user-one::acct-one' + $secondKey = 'user-two::acct-two' + $registryPath = Join-Path $accountsDir 'registry.json' + $registry = @{ + schema_version = 3 + active_account_key = $firstKey + active_account_activated_at_ms = 1735689600000 + auto_switch = @{ + enabled = $false + threshold_5h_percent = 12 + threshold_weekly_percent = 7 + } + api = @{ + usage = $false + account = $false + } + accounts = @( + @{ + account_key = $firstKey + chatgpt_account_id = 'acct-one' + chatgpt_user_id = 'user-one' + email = 'first@example.com' + alias = 'first' + account_name = $null + plan = 'plus' + auth_mode = 'chatgpt' + created_at = 1 + last_used_at = $null + last_usage = $null + last_usage_at = $null + last_local_rollout = $null + }, + @{ + account_key = $secondKey + chatgpt_account_id = 'acct-two' + chatgpt_user_id = 'user-two' + email = 'second@example.com' + alias = 'second' + account_name = $null + plan = 'plus' + auth_mode = 'chatgpt' + created_at = 2 + last_used_at = $null + last_usage = $null + last_usage_at = $null + last_local_rollout = $null + } + ) + } + + $registry | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $registryPath -Encoding utf8 + '{"account":"first"}' | Set-Content -LiteralPath (Get-AccountSnapshotPath $codexHome $firstKey) -Encoding utf8 + '{"account":"second"}' | Set-Content -LiteralPath (Get-AccountSnapshotPath $codexHome $secondKey) -Encoding utf8 + '{"account":"first"}' | Set-Content -LiteralPath (Join-Path $codexHome 'auth.json') -Encoding utf8 + + return [pscustomobject]@{ + codex_home = $codexHome + registry_path = $registryPath + first_key = $firstKey + second_key = $secondKey + } +} + +function Invoke-InteractiveWindow( + [string]$ExeLocalPath, + [string]$BaseDir, + [string]$CodexHome, + [string]$WindowTitle, + [string]$Command, + [scriptblock]$SendKeysBlock +) { + $cmdArgs = "/c title $WindowTitle & set CODEX_HOME=$CodexHome & set CODEX_AUTH_SKIP_SERVICE_RECONCILE=1 & `"$ExeLocalPath`" $Command" + $proc = Start-Process -FilePath 'cmd.exe' -ArgumentList $cmdArgs -WorkingDirectory $BaseDir -PassThru -WindowStyle Normal + + $wshell = New-Object -ComObject WScript.Shell + $activated = $false + for ($i = 0; $i -lt 40; $i++) { + Start-Sleep -Milliseconds 250 + if ($wshell.AppActivate($WindowTitle)) { + $activated = $true + Start-Sleep -Milliseconds 400 + & $SendKeysBlock $wshell + break + } + } + + $timedOut = $false + if (-not $proc.WaitForExit($TimeoutMs)) { + $timedOut = $true + try { $proc.Kill($true) } catch {} + } + + return [pscustomobject]@{ + activated_window = $activated + timed_out = $timedOut + exit_code = if ($proc.HasExited) { $proc.ExitCode } else { $null } + } +} + +function Invoke-SwitchSmoke([string]$ExeLocalPath, [string]$BaseDir) { + $layout = New-SmokeLayout $BaseDir + $windowTitle = 'codex-auth-switch-smoke-' + [Guid]::NewGuid().ToString('N') + $interaction = Invoke-InteractiveWindow $ExeLocalPath $BaseDir $layout.codex_home $windowTitle 'switch --skip-api' { + param($wshell) + $wshell.SendKeys('{DOWN}') + Start-Sleep -Milliseconds 150 + $wshell.SendKeys('~') + } + + $registryAfter = Get-Content -LiteralPath $layout.registry_path -Raw | ConvertFrom-Json + $authJson = Get-Content -LiteralPath (Join-Path $layout.codex_home 'auth.json') -Raw + + return [pscustomobject]@{ + scenario = 'switch' + base = $BaseDir + activated_window = $interaction.activated_window + timed_out = $interaction.timed_out + exit_code = $interaction.exit_code + active_account_key = $registryAfter.active_account_key + switched_to_second_account = ($registryAfter.active_account_key -eq $layout.second_key) + auth_json = $authJson.TrimEnd() + } +} + +function Invoke-RemoveSmoke([string]$ExeLocalPath, [string]$BaseDir) { + $layout = New-SmokeLayout $BaseDir + $windowTitle = 'codex-auth-remove-smoke-' + [Guid]::NewGuid().ToString('N') + $interaction = Invoke-InteractiveWindow $ExeLocalPath $BaseDir $layout.codex_home $windowTitle 'remove --skip-api' { + param($wshell) + $wshell.SendKeys('{DOWN}') + Start-Sleep -Milliseconds 150 + $wshell.SendKeys(' ') + Start-Sleep -Milliseconds 150 + $wshell.SendKeys('~') + } + + $registryAfter = Get-Content -LiteralPath $layout.registry_path -Raw | ConvertFrom-Json + $authJson = Get-Content -LiteralPath (Join-Path $layout.codex_home 'auth.json') -Raw + $remainingEmails = @($registryAfter.accounts | ForEach-Object { $_.email }) + + return [pscustomobject]@{ + scenario = 'remove' + base = $BaseDir + activated_window = $interaction.activated_window + timed_out = $interaction.timed_out + exit_code = $interaction.exit_code + remaining_count = $remainingEmails.Count + remaining_emails = $remainingEmails + removed_second_account = ($remainingEmails.Count -eq 1 -and $remainingEmails[0] -eq 'first@example.com') + active_account_key = $registryAfter.active_account_key + auth_json = $authJson.TrimEnd() + } +} + +Assert-WindowsLocalPath 'ExePath' $ExePath +Assert-WindowsLocalPath 'TestRoot' $TestRoot +if ($PSCommandPath) { + Assert-WindowsLocalPath 'Script path' $PSCommandPath +} +if (-not (Test-Path -LiteralPath $ExePath)) { + throw "ExePath does not exist: $ExePath" +} + +$baseRoot = Join-Path $TestRoot ('codex-auth-tui-smoke-' + [Guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Force -Path $baseRoot + +$results = @() +switch ($Scenario) { + 'switch' { + $results += Invoke-SwitchSmoke -ExeLocalPath $ExePath -BaseDir (Join-Path $baseRoot 'switch') + } + 'remove' { + $results += Invoke-RemoveSmoke -ExeLocalPath $ExePath -BaseDir (Join-Path $baseRoot 'remove') + } + 'both' { + $results += Invoke-SwitchSmoke -ExeLocalPath $ExePath -BaseDir (Join-Path $baseRoot 'switch') + $results += Invoke-RemoveSmoke -ExeLocalPath $ExePath -BaseDir (Join-Path $baseRoot 'remove') + } +} + +$payload = [pscustomobject]@{ + test_root = $baseRoot + results = $results +} + +$json = $payload | ConvertTo-Json -Depth 8 +if ($OutputJsonPath) { + Assert-WindowsLocalPath 'OutputJsonPath' $OutputJsonPath + $json | Set-Content -LiteralPath $OutputJsonPath -Encoding utf8 +} +$json diff --git a/src/cli.zig b/src/cli.zig index ab1bd50..823f860 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -6,9 +6,92 @@ const registry = @import("registry.zig"); const io_util = @import("io_util.zig"); const timefmt = @import("timefmt.zig"); const version = @import("version.zig"); +const windows = std.os.windows; const c = @cImport({ @cInclude("time.h"); }); +const win = struct { + const BOOL = windows.BOOL; + const CHAR = windows.CHAR; + const DWORD = windows.DWORD; + const HANDLE = windows.HANDLE; + const SHORT = windows.SHORT; + const WCHAR = windows.WCHAR; + const WORD = windows.WORD; + + const ENABLE_PROCESSED_INPUT: DWORD = 0x0001; + const ENABLE_LINE_INPUT: DWORD = 0x0002; + const ENABLE_ECHO_INPUT: DWORD = 0x0004; + const ENABLE_WINDOW_INPUT: DWORD = 0x0008; + const ENABLE_MOUSE_INPUT: DWORD = 0x0010; + const ENABLE_QUICK_EDIT_MODE: DWORD = 0x0040; + const ENABLE_EXTENDED_FLAGS: DWORD = 0x0080; + const ENABLE_VIRTUAL_TERMINAL_INPUT: DWORD = 0x0200; + + const ENABLE_PROCESSED_OUTPUT: DWORD = 0x0001; + const ENABLE_VIRTUAL_TERMINAL_PROCESSING: DWORD = windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING; + + const KEY_EVENT: WORD = 0x0001; + const WINDOW_BUFFER_SIZE_EVENT: WORD = 0x0004; + + const VK_BACK: WORD = 0x08; + const VK_RETURN: WORD = 0x0D; + const VK_ESCAPE: WORD = 0x1B; + const VK_UP: WORD = 0x26; + const VK_DOWN: WORD = 0x28; + + const WAIT_OBJECT_0: DWORD = 0x00000000; + const WAIT_TIMEOUT: DWORD = 258; + const INFINITE: DWORD = 0xFFFF_FFFF; + + const KEY_EVENT_RECORD = extern struct { + bKeyDown: BOOL, + wRepeatCount: WORD, + wVirtualKeyCode: WORD, + wVirtualScanCode: WORD, + uChar: extern union { + UnicodeChar: WCHAR, + AsciiChar: CHAR, + }, + dwControlKeyState: DWORD, + }; + + const COORD = extern struct { + X: SHORT, + Y: SHORT, + }; + + const WINDOW_BUFFER_SIZE_RECORD = extern struct { + dwSize: COORD, + }; + + const INPUT_RECORD = extern struct { + EventType: WORD, + Event: extern union { + KeyEvent: KEY_EVENT_RECORD, + WindowBufferSizeEvent: WINDOW_BUFFER_SIZE_RECORD, + }, + }; + + extern "kernel32" fn GetConsoleMode( + console_handle: HANDLE, + mode: *DWORD, + ) callconv(.winapi) BOOL; + extern "kernel32" fn SetConsoleMode( + console_handle: HANDLE, + mode: DWORD, + ) callconv(.winapi) BOOL; + extern "kernel32" fn ReadConsoleInputW( + console_input: HANDLE, + buffer: *INPUT_RECORD, + length: DWORD, + number_of_events_read: *DWORD, + ) callconv(.winapi) BOOL; + extern "kernel32" fn WaitForSingleObject( + handle: HANDLE, + milliseconds: DWORD, + ) callconv(.winapi) DWORD; +}; const ansi = struct { const reset = "\x1b[0m"; @@ -57,22 +140,66 @@ const TuiPollResult = enum { closed, }; -fn pollTuiInput(file: std.Io.File, timeout_ms: i32, poll_error_mask: i16) !TuiPollResult { - if (comptime builtin.os.tag == .windows) { - return .closed; - } else { - var fds = [_]std.posix.pollfd{.{ - .fd = file.handle, - .events = tui_poll_input_mask, - .revents = 0, - }}; - const ready = try std.posix.poll(&fds, timeout_ms); - if (ready == 0) return .timeout; - if ((fds[0].revents & poll_error_mask) != 0) return .closed; - return .ready; - } +const TuiInputKey = union(enum) { + move_up, + move_down, + enter, + quit, + backspace, + redraw, + byte: u8, +}; + +fn windowsTuiInputMode(saved_input_mode: win.DWORD) win.DWORD { + var raw_input_mode = saved_input_mode | + win.ENABLE_EXTENDED_FLAGS | + win.ENABLE_WINDOW_INPUT; + // Keep resize events enabled for redraws, but leave mouse explicitly disabled + // until the TUI has a real click/scroll interaction model. + raw_input_mode &= ~@as( + win.DWORD, + win.ENABLE_PROCESSED_INPUT | + win.ENABLE_QUICK_EDIT_MODE | + win.ENABLE_LINE_INPUT | + win.ENABLE_ECHO_INPUT | + win.ENABLE_MOUSE_INPUT | + win.ENABLE_VIRTUAL_TERMINAL_INPUT, + ); + return raw_input_mode; } +fn windowsTuiOutputMode(saved_output_mode: win.DWORD) win.DWORD { + return saved_output_mode | + win.ENABLE_PROCESSED_OUTPUT | + win.ENABLE_VIRTUAL_TERMINAL_PROCESSING; +} + +const pollTuiInput = if (builtin.os.tag == .windows) + struct { + fn call(file: std.Io.File, timeout_ms: i32, _: i16) !TuiPollResult { + const wait_ms: win.DWORD = if (timeout_ms < 0) win.INFINITE else @intCast(timeout_ms); + return switch (win.WaitForSingleObject(file.handle, wait_ms)) { + win.WAIT_OBJECT_0 => .ready, + win.WAIT_TIMEOUT => .timeout, + else => .closed, + }; + } + }.call +else + struct { + fn call(file: std.Io.File, timeout_ms: i32, poll_error_mask: i16) !TuiPollResult { + var fds = [_]std.posix.pollfd{.{ + .fd = file.handle, + .events = tui_poll_input_mask, + .revents = 0, + }}; + const ready = try std.posix.poll(&fds, timeout_ms); + if (ready == 0) return .timeout; + if ((fds[0].revents & poll_error_mask) != 0) return .closed; + return .ready; + } + }.call; + fn writeTuiEnterTo(out: *std.Io.Writer) !void { try out.writeAll("\x1b[?1049h\x1b[?25l"); try out.writeAll("\x1b[H\x1b[J"); @@ -98,12 +225,25 @@ fn writeRemoveTuiFooter(out: *std.Io.Writer, use_color: bool) !void { if (use_color) try out.writeAll(ansi.reset); } -const TuiSavedState = if (builtin.os.tag == .windows) void else std.posix.termios; +fn writeTuiPromptLine(out: *std.Io.Writer, prompt: []const u8, digits: []const u8) !void { + try out.writeAll(prompt); + if (digits.len != 0) { + try out.writeAll(" "); + try out.writeAll(digits); + } + try out.writeAll("\n"); +} + +const TuiSavedInputState = if (builtin.os.tag == .windows) win.DWORD else std.posix.termios; +const TuiSavedOutputState = if (builtin.os.tag == .windows) win.DWORD else void; const TuiSession = struct { input: std.Io.File, output: std.Io.File, - saved_termios: TuiSavedState = if (builtin.os.tag == .windows) {} else undefined, + saved_input_state: TuiSavedInputState = if (builtin.os.tag == .windows) 0 else undefined, + saved_output_state: TuiSavedOutputState = if (builtin.os.tag == .windows) 0 else {}, + pending_windows_key: ?TuiInputKey = null, + pending_windows_repeat_count: u16 = 0, writer_buffer: [4096]u8 = undefined, writer: std.Io.File.Writer = undefined, @@ -115,34 +255,66 @@ const TuiSession = struct { } if (comptime builtin.os.tag == .windows) { - return error.TuiRequiresTty; - } + var saved_input_mode: win.DWORD = 0; + var saved_output_mode: win.DWORD = 0; + if (win.GetConsoleMode(input.handle, &saved_input_mode) == .FALSE) { + return error.TuiRequiresTty; + } + if (win.GetConsoleMode(output.handle, &saved_output_mode) == .FALSE) { + return error.TuiRequiresTty; + } - const saved_termios = try std.posix.tcgetattr(input.handle); - var raw = saved_termios; - raw.lflag.ICANON = false; - raw.lflag.ECHO = false; - raw.cc[@intFromEnum(std.c.V.MIN)] = 1; - raw.cc[@intFromEnum(std.c.V.TIME)] = 0; - try std.posix.tcsetattr(input.handle, .FLUSH, raw); - errdefer std.posix.tcsetattr(input.handle, .FLUSH, saved_termios) catch {}; - - var session = @This(){ - .input = input, - .output = output, - .saved_termios = saved_termios, - }; - session.writer = session.output.writer(app_runtime.io(), &session.writer_buffer); - try session.enter(); - return session; + const raw_input_mode = windowsTuiInputMode(saved_input_mode); + if (win.SetConsoleMode(input.handle, raw_input_mode) == .FALSE) { + return error.TuiRequiresTty; + } + errdefer _ = win.SetConsoleMode(input.handle, saved_input_mode); + + const raw_output_mode = windowsTuiOutputMode(saved_output_mode); + if (win.SetConsoleMode(output.handle, raw_output_mode) == .FALSE) { + return error.TuiRequiresTty; + } + errdefer _ = win.SetConsoleMode(output.handle, saved_output_mode); + + var session = @This(){ + .input = input, + .output = output, + .saved_input_state = saved_input_mode, + .saved_output_state = saved_output_mode, + }; + session.writer = session.output.writer(app_runtime.io(), &session.writer_buffer); + try session.enter(); + return session; + } else { + const saved_termios = try std.posix.tcgetattr(input.handle); + var raw = saved_termios; + raw.lflag.ICANON = false; + raw.lflag.ECHO = false; + raw.cc[@intFromEnum(std.c.V.MIN)] = 1; + raw.cc[@intFromEnum(std.c.V.TIME)] = 0; + try std.posix.tcsetattr(input.handle, .FLUSH, raw); + errdefer std.posix.tcsetattr(input.handle, .FLUSH, saved_termios) catch {}; + + var session = @This(){ + .input = input, + .output = output, + .saved_input_state = saved_termios, + }; + session.writer = session.output.writer(app_runtime.io(), &session.writer_buffer); + try session.enter(); + return session; + } } fn deinit(self: *@This()) void { const writer = self.out(); writeTuiExitTo(writer) catch {}; writer.flush() catch {}; - if (comptime builtin.os.tag != .windows) { - std.posix.tcsetattr(self.input.handle, .FLUSH, self.saved_termios) catch {}; + if (comptime builtin.os.tag == .windows) { + _ = win.SetConsoleMode(self.output.handle, self.saved_output_state); + _ = win.SetConsoleMode(self.input.handle, self.saved_input_state); + } else { + std.posix.tcsetattr(self.input.handle, .FLUSH, self.saved_input_state) catch {}; } self.* = undefined; } @@ -155,6 +327,58 @@ const TuiSession = struct { return try readFileOnce(self.input, buffer); } + fn readWindowsKey(self: *@This()) !TuiInputKey { + if (comptime builtin.os.tag != .windows) unreachable; + + if (self.pending_windows_key) |pending| { + if (self.pending_windows_repeat_count > 1) { + self.pending_windows_repeat_count -= 1; + } else { + self.pending_windows_repeat_count = 0; + self.pending_windows_key = null; + } + return pending; + } + + while (true) { + var record: win.INPUT_RECORD = undefined; + var events_read: win.DWORD = 0; + if (win.ReadConsoleInputW(self.input.handle, &record, 1, &events_read) == .FALSE) { + return error.EndOfStream; + } + if (events_read == 0) continue; + if (record.EventType == win.WINDOW_BUFFER_SIZE_EVENT) { + self.pending_windows_key = null; + self.pending_windows_repeat_count = 0; + return .redraw; + } + if (record.EventType != win.KEY_EVENT) continue; + + const key_event = record.Event.KeyEvent; + if (key_event.bKeyDown == .FALSE) continue; + + const key = switch (key_event.wVirtualKeyCode) { + win.VK_UP => TuiInputKey.move_up, + win.VK_DOWN => TuiInputKey.move_down, + win.VK_RETURN => TuiInputKey.enter, + win.VK_ESCAPE => TuiInputKey.quit, + win.VK_BACK => TuiInputKey.backspace, + else => blk: { + const codepoint = key_event.uChar.UnicodeChar; + if (codepoint == 0 or codepoint > 0x7f) continue; + break :blk TuiInputKey{ .byte = @intCast(codepoint) }; + }, + }; + + const repeat_count = if (key_event.wRepeatCount == 0) 1 else key_event.wRepeatCount; + if (repeat_count > 1) { + self.pending_windows_key = key; + self.pending_windows_repeat_count = repeat_count - 1; + } + return key; + } + } + fn enter(self: *@This()) !void { const writer = self.out(); try writeTuiEnterTo(writer); @@ -313,6 +537,40 @@ test "Scenario: Given shared TUI frame redraw when writing it then it clears onl try std.testing.expect(std.mem.indexOf(u8, aw.written(), "\x1b[2J\x1b[H") == null); } +test "Scenario: Given TUI prompt with numeric input when rendering then the current digits stay inline with the title" { + const gpa = std.testing.allocator; + var with_digits: std.Io.Writer.Allocating = .init(gpa); + defer with_digits.deinit(); + var without_digits: std.Io.Writer.Allocating = .init(gpa); + defer without_digits.deinit(); + + try writeTuiPromptLine(&with_digits.writer, "Select account to activate:", "123"); + try std.testing.expectEqualStrings("Select account to activate: 123\n", with_digits.written()); + + try writeTuiPromptLine(&without_digits.writer, "Select account to activate:", ""); + try std.testing.expectEqualStrings("Select account to activate:\n", without_digits.written()); +} + +test "Scenario: Given Windows TUI console modes when configuring them then resize stays enabled while mouse and cooked input stay disabled" { + const saved_input_mode: win.DWORD = + win.ENABLE_MOUSE_INPUT | + win.ENABLE_WINDOW_INPUT | + win.ENABLE_LINE_INPUT | + win.ENABLE_ECHO_INPUT; + const configured_input_mode = windowsTuiInputMode(saved_input_mode); + + try std.testing.expect((configured_input_mode & win.ENABLE_WINDOW_INPUT) != 0); + try std.testing.expect((configured_input_mode & win.ENABLE_EXTENDED_FLAGS) != 0); + try std.testing.expect((configured_input_mode & win.ENABLE_MOUSE_INPUT) == 0); + try std.testing.expect((configured_input_mode & win.ENABLE_LINE_INPUT) == 0); + try std.testing.expect((configured_input_mode & win.ENABLE_ECHO_INPUT) == 0); + try std.testing.expect((configured_input_mode & win.ENABLE_VIRTUAL_TERMINAL_INPUT) == 0); + + const configured_output_mode = windowsTuiOutputMode(0); + try std.testing.expect((configured_output_mode & win.ENABLE_PROCESSED_OUTPUT) != 0); + try std.testing.expect((configured_output_mode & win.ENABLE_VIRTUAL_TERMINAL_PROCESSING) != 0); +} + fn colorEnabled() bool { return std.Io.File.stdout().isTty(app_runtime.io()) catch false; } @@ -1669,6 +1927,7 @@ pub fn selectAccountWithLiveUpdates( selected_idx, use_color, status_line, + number_buf[0..number_len], ); try out.flush(); @@ -1681,6 +1940,74 @@ pub fn selectAccountWithLiveUpdates( .ready => {}, } + if (comptime builtin.os.tag == .windows) { + switch (try tui.readWindowsKey()) { + .move_up => { + if (selected_idx > 0) { + selected_idx -= 1; + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selected_idx); + number_len = 0; + } + }, + .move_down => { + if (selected_idx + 1 < rows.selectable_row_indices.len) { + selected_idx += 1; + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selected_idx); + number_len = 0; + } + }, + .enter => { + if (number_len > 0) { + const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; + if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { + return try dupSelectedAccountKey(allocator, &rows, borrowed.reg, parsed - 1); + } + } + return try dupSelectedAccountKey(allocator, &rows, borrowed.reg, selected_idx); + }, + .quit => return null, + .backspace => { + if (number_len > 0) { + number_len -= 1; + if (number_len > 0) { + const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; + if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { + selected_idx = parsed - 1; + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selected_idx); + } + } + } + }, + .redraw => continue, + .byte => |ch| { + if (isQuitKey(ch)) return null; + + if (ch == 'k' and selected_idx > 0) { + selected_idx -= 1; + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selected_idx); + number_len = 0; + continue; + } + if (ch == 'j' and selected_idx + 1 < rows.selectable_row_indices.len) { + selected_idx += 1; + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selected_idx); + number_len = 0; + continue; + } + if (ch >= '0' and ch <= '9' and number_len < number_buf.len) { + number_buf[number_len] = ch; + number_len += 1; + const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; + if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { + selected_idx = parsed - 1; + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selected_idx); + } + } + }, + } + continue; + } + var b: [8]u8 = undefined; const n = try tui.read(&b); if (n == 0) return null; @@ -1791,7 +2118,8 @@ pub fn selectAccountFromIndicesWithUsageOverrides( } pub fn shouldUseNumberedSwitchSelector(is_windows: bool, stdin_is_tty: bool, stdout_is_tty: bool) bool { - return is_windows or !stdin_is_tty or !stdout_is_tty; + _ = is_windows; + return !stdin_is_tty or !stdout_is_tty; } pub fn selectAccountsToRemove(allocator: std.mem.Allocator, reg: *registry.Registry) !?[]usize { @@ -1803,17 +2131,19 @@ pub fn selectAccountsToRemoveWithUsageOverrides( reg: *registry.Registry, usage_overrides: ?[]const ?[]const u8, ) !?[]usize { - if (comptime builtin.os.tag == .windows) { - return selectRemoveWithNumbers(allocator, reg, usage_overrides); - } - if (shouldUseNumberedRemoveSelector(false, std.Io.File.stdin().isTty(app_runtime.io()) catch false)) { + if (shouldUseNumberedRemoveSelector( + comptime builtin.os.tag == .windows, + std.Io.File.stdin().isTty(app_runtime.io()) catch false, + std.Io.File.stdout().isTty(app_runtime.io()) catch false, + )) { return selectRemoveWithNumbers(allocator, reg, usage_overrides); } return try selectRemoveInteractive(allocator, reg, usage_overrides); } -pub fn shouldUseNumberedRemoveSelector(is_windows: bool, stdin_is_tty: bool) bool { - return is_windows or !stdin_is_tty; +pub fn shouldUseNumberedRemoveSelector(is_windows: bool, stdin_is_tty: bool, stdout_is_tty: bool) bool { + _ = is_windows; + return !stdin_is_tty or !stdout_is_tty; } fn isQuitInput(input: []const u8) bool { @@ -1978,12 +2308,80 @@ fn selectInteractiveFromIndices( while (true) { try tui.resetFrame(); - try out.writeAll("Select account to activate:\n\n"); - try renderSwitchList(out, reg, rows.items, idx_width, widths, idx, use_color); - try out.writeAll("\n"); - try writeSwitchTuiFooter(out, use_color); + try renderSwitchScreen( + out, + reg, + rows.items, + idx_width, + widths, + idx, + use_color, + "", + number_buf[0..number_len], + ); try out.flush(); + if (comptime builtin.os.tag == .windows) { + switch (try tui.readWindowsKey()) { + .move_up => { + if (idx > 0) { + idx -= 1; + number_len = 0; + } + }, + .move_down => { + if (idx + 1 < rows.selectable_row_indices.len) { + idx += 1; + number_len = 0; + } + }, + .enter => { + if (number_len > 0) { + const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; + if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { + return accountIdForSelectable(&rows, reg, parsed - 1); + } + } + return accountIdForSelectable(&rows, reg, idx); + }, + .quit => return null, + .backspace => { + if (number_len > 0) { + number_len -= 1; + if (number_len > 0) { + const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; + if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { + idx = parsed - 1; + } + } + } + }, + .redraw => continue, + .byte => |ch| { + if (isQuitKey(ch)) return null; + if (ch == 'k' and idx > 0) { + idx -= 1; + number_len = 0; + continue; + } + if (ch == 'j' and idx + 1 < rows.selectable_row_indices.len) { + idx += 1; + number_len = 0; + continue; + } + if (ch >= '0' and ch <= '9' and number_len < number_buf.len) { + number_buf[number_len] = ch; + number_len += 1; + const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; + if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { + idx = parsed - 1; + } + } + }, + } + continue; + } + var b: [8]u8 = undefined; const n = try tui.read(&b); var i: usize = 0; @@ -2160,12 +2558,80 @@ fn selectInteractive( while (true) { try tui.resetFrame(); - try out.writeAll("Select account to activate:\n\n"); - try renderSwitchList(out, reg, rows.items, idx_width, widths, idx, use_color); - try out.writeAll("\n"); - try writeSwitchTuiFooter(out, use_color); + try renderSwitchScreen( + out, + reg, + rows.items, + idx_width, + widths, + idx, + use_color, + "", + number_buf[0..number_len], + ); try out.flush(); + if (comptime builtin.os.tag == .windows) { + switch (try tui.readWindowsKey()) { + .move_up => { + if (idx > 0) { + idx -= 1; + number_len = 0; + } + }, + .move_down => { + if (idx + 1 < rows.selectable_row_indices.len) { + idx += 1; + number_len = 0; + } + }, + .enter => { + if (number_len > 0) { + const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; + if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { + return accountIdForSelectable(&rows, reg, parsed - 1); + } + } + return accountIdForSelectable(&rows, reg, idx); + }, + .quit => return null, + .backspace => { + if (number_len > 0) { + number_len -= 1; + if (number_len > 0) { + const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; + if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { + idx = parsed - 1; + } + } + } + }, + .redraw => continue, + .byte => |ch| { + if (isQuitKey(ch)) return null; + if (ch == 'k' and idx > 0) { + idx -= 1; + number_len = 0; + continue; + } + if (ch == 'j' and idx + 1 < rows.selectable_row_indices.len) { + idx += 1; + number_len = 0; + continue; + } + if (ch >= '0' and ch <= '9' and number_len < number_buf.len) { + number_buf[number_len] = ch; + number_len += 1; + const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; + if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { + idx = parsed - 1; + } + } + }, + } + continue; + } + var b: [8]u8 = undefined; const n = try tui.read(&b); var i: usize = 0; @@ -2269,12 +2735,85 @@ fn selectRemoveInteractive( while (true) { try tui.resetFrame(); - try out.writeAll("Select accounts to delete:\n\n"); + try writeTuiPromptLine(out, "Select accounts to delete:", number_buf[0..number_len]); + try out.writeAll("\n"); try renderRemoveList(out, reg, rows.items, idx_width, widths, idx, checked, use_color); try out.writeAll("\n"); try writeRemoveTuiFooter(out, use_color); try out.flush(); + if (comptime builtin.os.tag == .windows) { + switch (try tui.readWindowsKey()) { + .move_up => { + if (idx > 0) { + idx -= 1; + number_len = 0; + } + }, + .move_down => { + if (idx + 1 < rows.selectable_row_indices.len) { + idx += 1; + number_len = 0; + } + }, + .enter => { + var count: usize = 0; + for (checked) |flag| { + if (flag) count += 1; + } + if (count == 0) return null; + var selected = try allocator.alloc(usize, count); + var out_idx: usize = 0; + for (checked, 0..) |flag, sel_idx| { + if (!flag) continue; + selected[out_idx] = accountIndexForSelectable(&rows, sel_idx); + out_idx += 1; + } + return selected; + }, + .quit => return null, + .backspace => { + if (number_len > 0) { + number_len -= 1; + if (number_len > 0) { + const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; + if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { + idx = parsed - 1; + } + } + } + }, + .redraw => continue, + .byte => |ch| { + if (isQuitKey(ch)) return null; + if (ch == 'k' and idx > 0) { + idx -= 1; + number_len = 0; + continue; + } + if (ch == 'j' and idx + 1 < rows.selectable_row_indices.len) { + idx += 1; + number_len = 0; + continue; + } + if (ch == ' ') { + checked[idx] = !checked[idx]; + number_len = 0; + continue; + } + if (ch >= '0' and ch <= '9' and number_len < number_buf.len) { + number_buf[number_len] = ch; + number_len += 1; + const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; + if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { + idx = parsed - 1; + } + } + }, + } + continue; + } + var b: [8]u8 = undefined; const n = try tui.read(&b); var i: usize = 0; @@ -2373,8 +2912,10 @@ fn renderSwitchScreen( selected: ?usize, use_color: bool, status_line: []const u8, + number_input: []const u8, ) !void { - try out.writeAll("Select account to activate:\n\n"); + try writeTuiPromptLine(out, "Select account to activate:", number_input); + try out.writeAll("\n"); try renderSwitchList(out, reg, rows, idx_width, widths, selected, use_color); try out.writeAll("\n"); if (status_line.len != 0) { diff --git a/src/tests/cli_bdd_test.zig b/src/tests/cli_bdd_test.zig index 8292e49..a7a3249 100644 --- a/src/tests/cli_bdd_test.zig +++ b/src/tests/cli_bdd_test.zig @@ -965,8 +965,14 @@ test "Scenario: Given singleton account names from different emails when buildin try std.testing.expectEqualStrings("beta@example.com / Workspace", labels.items[1]); } -test "Scenario: Given selector environment when deciding remove UI then non-tty or windows use the numbered selector" { - try std.testing.expect(cli.shouldUseNumberedRemoveSelector(false, false)); - try std.testing.expect(!cli.shouldUseNumberedRemoveSelector(false, true)); - try std.testing.expect(cli.shouldUseNumberedRemoveSelector(true, true)); +test "Scenario: Given selector environment when deciding switch or remove UI then only non-tty streams use the numbered selector" { + try std.testing.expect(cli.shouldUseNumberedSwitchSelector(false, false, true)); + try std.testing.expect(cli.shouldUseNumberedSwitchSelector(false, true, false)); + try std.testing.expect(!cli.shouldUseNumberedSwitchSelector(false, true, true)); + try std.testing.expect(!cli.shouldUseNumberedSwitchSelector(true, true, true)); + + try std.testing.expect(cli.shouldUseNumberedRemoveSelector(false, false, true)); + try std.testing.expect(cli.shouldUseNumberedRemoveSelector(false, true, false)); + try std.testing.expect(!cli.shouldUseNumberedRemoveSelector(false, true, true)); + try std.testing.expect(!cli.shouldUseNumberedRemoveSelector(true, true, true)); } From f33749fc898ab09b533230db25d6e92542739d7d Mon Sep 17 00:00:00 2001 From: Loongphy Date: Mon, 20 Apr 2026 16:25:18 +0800 Subject: [PATCH 07/11] feat: add live modes for account commands --- README.md | 21 +- src/cli.zig | 1517 +++++++++++++++++++++++++++++------- src/main.zig | 582 +++++++++++--- src/tests/cli_bdd_test.zig | 76 +- src/tests/e2e_cli_test.zig | 60 +- 5 files changed, 1839 insertions(+), 417 deletions(-) diff --git a/README.md b/README.md index 8a43864..373e8c9 100644 --- a/README.md +++ b/README.md @@ -94,11 +94,12 @@ Remove-Item "$env:LOCALAPPDATA\codex-auth\bin\codex-auth-auto.exe" -Force -Error | Command | Description | |---------|-------------| -| `codex-auth list [--debug] [--api|--skip-api]` | List all accounts. `--api` forces a live refresh, while `--skip-api` uses only stored local usage and team-name data. | +| `codex-auth list [--debug] [--live] [--api|--skip-api]` | List all accounts. `--live` keeps refreshing the terminal view; `--api` forces remote refresh, while `--skip-api` forbids remote API use for this command. | | `codex-auth login [--device-auth]` | Run `codex login` (optionally with `--device-auth`), then add the current account | -| `codex-auth switch [--api|--skip-api]` | Switch the active account interactively. `--api` forces a live refresh first; `--skip-api` stays local-only. | +| `codex-auth switch [--live] [--api|--skip-api]` | Switch the active account interactively. Without `--live` it exits after one switch; with `--live` it stays open and keeps refreshing. | | `codex-auth switch ` | Switch the active account directly by row number, alias, or fuzzy match using stored local data only. | -| `codex-auth remove [--api|--skip-api] [...]` | Interactive remove stays local-only by default; `--api` attempts a best-effort live refresh for picker display, while selector-based removal still resolves from stored local data only. | +| `codex-auth remove [--live] [--api|--skip-api]` | Interactive remove. `--live` keeps the picker open after each deletion; `--api` forces remote refresh and `--skip-api` forbids remote API use for this command. | +| `codex-auth remove [...]` | Remove one or more accounts by row number, alias, email, account name, or `account_key` match using stored local data. | | `codex-auth remove --all` | Remove all stored accounts. | | `codex-auth status` | Show auto-switch, service, and usage status | @@ -130,22 +131,24 @@ Remove-Item "$env:LOCALAPPDATA\codex-auth\bin\codex-auth-auto.exe" -Force -Error ```shell codex-auth list codex-auth list --debug +codex-auth list --live codex-auth list --api # force usage/team-name API refresh, even if config api is disabled -codex-auth list --skip-api # use only stored registry data; skip usage/team-name API refresh +codex-auth list --skip-api # forbid usage/team-name API refresh for this command ``` +`--live` keeps the list refreshing inside the terminal UI. `--api` forces the foreground usage and team-name refresh path for this command only. -`--skip-api` keeps the current local snapshot exactly as stored in `registry.json`. -It does not call the usage API or `accounts/check`, so transient live-refresh failures such as `401` and `403` are not preserved in this mode. +`--skip-api` forbids remote refresh for this command only. Usage can still refresh locally for the active account when local rollout data exists. ### Switch Account Interactive `switch` shows email, 5h, weekly, and last activity. -Without ``, it follows the configured refresh mode before opening the picker. -Use `--api` to force a foreground refresh first, or `--skip-api` to stay on stored local data only. +Without ``, it follows the configured refresh mode before opening the picker. `switch` is single-shot by default; `switch --live` keeps the picker open after Enter and updates the footer with the latest switch result. +Use `--api` to force a foreground remote refresh first, or `--skip-api` to forbid remote API use and rely on local-only usage refresh where available. ```shell codex-auth switch +codex-auth switch --live codex-auth switch --api codex-auth switch --skip-api ``` @@ -154,7 +157,7 @@ codex-auth switch --skip-api `` can be a displayed row number, an alias, or a fuzzy email/alias match. The row number follows the interactive `switch` list, and the same number from `codex-auth list` also works because both commands use the same ordering. -`switch ` always resolves from stored local data and does not accept `--api` or `--skip-api`. +`switch ` always resolves from stored local data and does not accept `--live`, `--api`, or `--skip-api`. ```shell codex-auth switch 02 # switch by displayed row number diff --git a/src/cli.zig b/src/cli.zig index 823f860..b70db04 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -225,6 +225,12 @@ fn writeRemoveTuiFooter(out: *std.Io.Writer, use_color: bool) !void { if (use_color) try out.writeAll(ansi.reset); } +fn writeListTuiFooter(out: *std.Io.Writer, use_color: bool) !void { + if (use_color) try out.writeAll(ansi.dim); + try out.writeAll("Keys: Esc or q quit\n"); + if (use_color) try out.writeAll(ansi.reset); +} + fn writeTuiPromptLine(out: *std.Io.Writer, prompt: []const u8, digits: []const u8) !void { try out.writeAll(prompt); if (digits.len != 0) { @@ -595,6 +601,7 @@ pub const ApiMode = enum { pub const ListOptions = struct { debug: bool = false, + live: bool = false, api_mode: ApiMode = .default, }; pub const LoginOptions = struct { @@ -609,11 +616,13 @@ pub const ImportOptions = struct { }; pub const SwitchOptions = struct { query: ?[]u8, + live: bool = false, api_mode: ApiMode = .default, }; pub const RemoveOptions = struct { selectors: [][]const u8, all: bool, + live: bool = false, api_mode: ApiMode = .default, }; pub const CleanOptions = struct {}; @@ -714,6 +723,13 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Pars opts.debug = true; continue; } + if (std.mem.eql(u8, arg, "--live")) { + if (opts.live) { + return usageErrorResult(allocator, .list, "duplicate `--live` for `list`.", .{}); + } + opts.live = true; + continue; + } if (std.mem.eql(u8, arg, "--api")) { switch (opts.api_mode) { .default => opts.api_mode = .force_api, @@ -842,6 +858,14 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Pars var i: usize = 2; while (i < args.len) : (i += 1) { const arg = std.mem.sliceTo(args[i], 0); + if (std.mem.eql(u8, arg, "--live")) { + if (opts.live) { + if (opts.query) |query| allocator.free(query); + return usageErrorResult(allocator, .switch_account, "duplicate `--live` for `switch`.", .{}); + } + opts.live = true; + continue; + } if (std.mem.eql(u8, arg, "--api")) { switch (opts.api_mode) { .default => opts.api_mode = .force_api, @@ -880,12 +904,12 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Pars } opts.query = try allocator.dupe(u8, arg); } - if (opts.query != null and opts.api_mode != .default) { + if (opts.query != null and (opts.api_mode != .default or opts.live)) { if (opts.query) |query| allocator.free(query); return usageErrorResult( allocator, .switch_account, - "`switch ` does not support `--api` or `--skip-api`.", + "`switch ` does not support `--live`, `--api`, or `--skip-api`.", .{}, ); } @@ -901,10 +925,18 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Pars errdefer freeOwnedStringList(allocator, selectors.items); defer selectors.deinit(allocator); var all = false; + var live = false; var api_mode: ApiMode = .default; var i: usize = 2; while (i < args.len) : (i += 1) { const arg = std.mem.sliceTo(args[i], 0); + if (std.mem.eql(u8, arg, "--live")) { + if (live) { + return usageErrorResult(allocator, .remove_account, "duplicate `--live` for `remove`.", .{}); + } + live = true; + continue; + } if (std.mem.eql(u8, arg, "--api")) { switch (api_mode) { .default => api_mode = .force_api, @@ -944,18 +976,19 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Pars } try selectors.append(allocator, try allocator.dupe(u8, arg)); } - if (api_mode != .default and (all or selectors.items.len != 0)) { + if ((live or api_mode != .default) and (all or selectors.items.len != 0)) { freeOwnedStringList(allocator, selectors.items); return usageErrorResult( allocator, .remove_account, - "`remove ` and `remove --all` do not support `--api` or `--skip-api`.", + "`remove ` and `remove --all` do not support `--live`, `--api`, or `--skip-api`.", .{}, ); } return .{ .command = .{ .remove_account = .{ .selectors = try selectors.toOwnedSlice(allocator), .all = all, + .live = live, .api_mode = api_mode, } } }; } @@ -1204,8 +1237,8 @@ pub fn writeHelp( .{ .name = "status", .description = "Show auto-switch and usage API status" }, .{ .name = "login", .description = "Login and add the current account" }, .{ .name = "import", .description = "Import auth files or rebuild registry" }, - .{ .name = "switch [--api|--skip-api] | switch ", .description = "Switch the active account" }, - .{ .name = "remove [...] | remove --all", .description = "Remove one or more accounts" }, + .{ .name = "switch [--live] [--api|--skip-api] | switch ", .description = "Switch the active account" }, + .{ .name = "remove [--live] [...] | remove --all", .description = "Remove one or more accounts" }, .{ .name = "clean", .description = "Delete backup and stale files under accounts/" }, .{ .name = "config", .description = "Manage configuration" }, }; @@ -1374,7 +1407,7 @@ fn writeUsageSection(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth --help\n"); try out.writeAll(" codex-auth help \n"); }, - .list => try out.writeAll(" codex-auth list [--debug] [--api|--skip-api]\n"), + .list => try out.writeAll(" codex-auth list [--debug] [--live] [--api|--skip-api]\n"), .status => try out.writeAll(" codex-auth status\n"), .login => { try out.writeAll(" codex-auth login\n"); @@ -1386,11 +1419,11 @@ fn writeUsageSection(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth import --purge []\n"); }, .switch_account => { - try out.writeAll(" codex-auth switch [--api|--skip-api]\n"); + try out.writeAll(" codex-auth switch [--live] [--api|--skip-api]\n"); try out.writeAll(" codex-auth switch \n"); }, .remove_account => { - try out.writeAll(" codex-auth remove [--api|--skip-api]\n"); + try out.writeAll(" codex-auth remove [--live] [--api|--skip-api]\n"); try out.writeAll(" codex-auth remove [...]\n"); try out.writeAll(" codex-auth remove --all\n"); }, @@ -1421,6 +1454,7 @@ fn writeExamplesSection(out: *std.Io.Writer, topic: HelpTopic) !void { .list => { try out.writeAll(" codex-auth list\n"); try out.writeAll(" codex-auth list --debug\n"); + try out.writeAll(" codex-auth list --live\n"); try out.writeAll(" codex-auth list --api\n"); try out.writeAll(" codex-auth list --skip-api\n"); }, @@ -1436,6 +1470,7 @@ fn writeExamplesSection(out: *std.Io.Writer, topic: HelpTopic) !void { }, .switch_account => { try out.writeAll(" codex-auth switch\n"); + try out.writeAll(" codex-auth switch --live\n"); try out.writeAll(" codex-auth switch --api\n"); try out.writeAll(" codex-auth switch --skip-api\n"); try out.writeAll(" codex-auth switch work\n"); @@ -1443,6 +1478,7 @@ fn writeExamplesSection(out: *std.Io.Writer, topic: HelpTopic) !void { }, .remove_account => { try out.writeAll(" codex-auth remove\n"); + try out.writeAll(" codex-auth remove --live\n"); try out.writeAll(" codex-auth remove --api\n"); try out.writeAll(" codex-auth remove --skip-api\n"); try out.writeAll(" codex-auth remove 01 03\n"); @@ -1612,6 +1648,18 @@ pub fn printSwitchRequiresTtyError() !void { try out.flush(); } +pub fn printListRequiresTtyError() !void { + var buffer: [512]u8 = undefined; + var writer = std.Io.File.stderr().writer(app_runtime.io(), &buffer); + const out = &writer.interface; + const use_color = stderrColorEnabled(); + try writeErrorPrefixTo(out, use_color); + try out.writeAll(" live list requires a TTY.\n"); + try writeHintPrefixTo(out, use_color); + try out.writeAll(" Run `codex-auth list --live` in a terminal.\n"); + try out.flush(); +} + pub fn printRemoveRequiresTtyError() !void { var buffer: [512]u8 = undefined; var writer = std.Io.File.stderr().writer(app_runtime.io(), &buffer); @@ -1862,6 +1910,29 @@ pub const SwitchLiveController = struct { ) anyerror![]u8, }; +pub const LiveActionOutcome = struct { + updated_display: OwnedSwitchSelectionDisplay, + action_message: ?[]u8 = null, +}; + +pub const SwitchLiveActionController = struct { + refresh: SwitchLiveController, + apply_selection: *const fn ( + context: *anyopaque, + allocator: std.mem.Allocator, + account_key: []const u8, + ) anyerror!LiveActionOutcome, +}; + +pub const RemoveLiveActionController = struct { + refresh: SwitchLiveController, + apply_selection: *const fn ( + context: *anyopaque, + allocator: std.mem.Allocator, + account_keys: []const []const u8, + ) anyerror!LiveActionOutcome, +}; + pub fn selectAccountWithLiveUpdates( allocator: std.mem.Allocator, initial_display: OwnedSwitchSelectionDisplay, @@ -1906,27 +1977,33 @@ pub fn selectAccountWithLiveUpdates( var rows = try buildSwitchRowsWithUsageOverrides(allocator, borrowed.reg, borrowed.usage_overrides); defer rows.deinit(allocator); try filterErroredRowsFromSelectableIndices(allocator, &rows); - if (rows.selectable_row_indices.len == 0) return null; - - var selected_idx = if (selected_account_key) |key| - selectableIndexForAccountKey(&rows, borrowed.reg, key) orelse activeSelectableIndex(&rows) orelse 0 - else - activeSelectableIndex(&rows) orelse 0; - try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selected_idx); + const total_accounts = accountRowCount(rows.items); + if (total_accounts == 0) return null; + + var selected_idx: ?usize = null; + if (rows.selectable_row_indices.len != 0) { + selected_idx = if (selected_account_key) |key| + selectableIndexForAccountKey(&rows, borrowed.reg, key) orelse activeSelectableIndex(&rows) orelse 0 + else + activeSelectableIndex(&rows) orelse 0; + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selected_idx.?); + } const status_line = try controller.build_status_line(controller.context, allocator, borrowed); defer allocator.free(status_line); + const selected_display_idx = selectedDisplayIndexForRender(&rows, selected_idx, number_buf[0..number_len]); try tui.resetFrame(); try renderSwitchScreen( out, borrowed.reg, rows.items, - @max(@as(usize, 2), indexWidth(rows.selectable_row_indices.len)), + @max(@as(usize, 2), indexWidth(total_accounts)), rows.widths, - selected_idx, + selected_display_idx, use_color, status_line, + "", number_buf[0..number_len], ); try out.flush(); @@ -1943,37 +2020,37 @@ pub fn selectAccountWithLiveUpdates( if (comptime builtin.os.tag == .windows) { switch (try tui.readWindowsKey()) { .move_up => { - if (selected_idx > 0) { - selected_idx -= 1; - try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selected_idx); - number_len = 0; + if (selected_idx) |idx| { + if (idx > 0) { + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, idx - 1); + number_len = 0; + } } }, .move_down => { - if (selected_idx + 1 < rows.selectable_row_indices.len) { - selected_idx += 1; - try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selected_idx); - number_len = 0; + if (selected_idx) |idx| { + if (idx + 1 < rows.selectable_row_indices.len) { + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, idx + 1); + number_len = 0; + } } }, .enter => { - if (number_len > 0) { - const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; - if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { - return try dupSelectedAccountKey(allocator, &rows, borrowed.reg, parsed - 1); - } + if (parsedDisplayedIndex(number_buf[0..number_len], total_accounts)) |displayed_idx| { + return try dupSelectedAccountKeyForDisplayedAccount(allocator, &rows, borrowed.reg, displayed_idx); } - return try dupSelectedAccountKey(allocator, &rows, borrowed.reg, selected_idx); + if (selected_idx) |idx| { + return try dupSelectedAccountKey(allocator, &rows, borrowed.reg, idx); + } + return null; }, .quit => return null, .backspace => { if (number_len > 0) { number_len -= 1; - if (number_len > 0) { - const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; - if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { - selected_idx = parsed - 1; - try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selected_idx); + if (parsedDisplayedIndex(number_buf[0..number_len], total_accounts)) |displayed_idx| { + if (selectableIndexForDisplayedAccount(&rows, displayed_idx)) |selectable_idx| { + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selectable_idx); } } } @@ -1982,25 +2059,31 @@ pub fn selectAccountWithLiveUpdates( .byte => |ch| { if (isQuitKey(ch)) return null; - if (ch == 'k' and selected_idx > 0) { - selected_idx -= 1; - try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selected_idx); - number_len = 0; + if (ch == 'k') { + if (selected_idx) |idx| { + if (idx > 0) { + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, idx - 1); + number_len = 0; + } + } continue; } - if (ch == 'j' and selected_idx + 1 < rows.selectable_row_indices.len) { - selected_idx += 1; - try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selected_idx); - number_len = 0; + if (ch == 'j') { + if (selected_idx) |idx| { + if (idx + 1 < rows.selectable_row_indices.len) { + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, idx + 1); + number_len = 0; + } + } continue; } if (ch >= '0' and ch <= '9' and number_len < number_buf.len) { number_buf[number_len] = ch; number_len += 1; - const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; - if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { - selected_idx = parsed - 1; - try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selected_idx); + if (parsedDisplayedIndex(number_buf[0..number_len], total_accounts)) |displayed_idx| { + if (selectableIndexForDisplayedAccount(&rows, displayed_idx)) |selectable_idx| { + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selectable_idx); + } } } }, @@ -2023,17 +2106,19 @@ pub fn selectAccountWithLiveUpdates( ); switch (escape.action) { .move_up => { - if (selected_idx > 0) { - selected_idx -= 1; - try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selected_idx); - number_len = 0; + if (selected_idx) |idx| { + if (idx > 0) { + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, idx - 1); + number_len = 0; + } } }, .move_down => { - if (selected_idx + 1 < rows.selectable_row_indices.len) { - selected_idx += 1; - try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selected_idx); - number_len = 0; + if (selected_idx) |idx| { + if (idx + 1 < rows.selectable_row_indices.len) { + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, idx + 1); + number_len = 0; + } } }, .quit => return null, @@ -2044,36 +2129,40 @@ pub fn selectAccountWithLiveUpdates( } if (b[i] == '\r' or b[i] == '\n') { - if (number_len > 0) { - const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; - if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { - return try dupSelectedAccountKey(allocator, &rows, borrowed.reg, parsed - 1); - } + if (parsedDisplayedIndex(number_buf[0..number_len], total_accounts)) |displayed_idx| { + return try dupSelectedAccountKeyForDisplayedAccount(allocator, &rows, borrowed.reg, displayed_idx); + } + if (selected_idx) |idx| { + return try dupSelectedAccountKey(allocator, &rows, borrowed.reg, idx); } - return try dupSelectedAccountKey(allocator, &rows, borrowed.reg, selected_idx); + return null; } if (isQuitKey(b[i])) return null; - if (b[i] == 'k' and selected_idx > 0) { - selected_idx -= 1; - try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selected_idx); - number_len = 0; + if (b[i] == 'k') { + if (selected_idx) |idx| { + if (idx > 0) { + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, idx - 1); + number_len = 0; + } + } continue; } - if (b[i] == 'j' and selected_idx + 1 < rows.selectable_row_indices.len) { - selected_idx += 1; - try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selected_idx); - number_len = 0; + if (b[i] == 'j') { + if (selected_idx) |idx| { + if (idx + 1 < rows.selectable_row_indices.len) { + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, idx + 1); + number_len = 0; + } + } continue; } if (b[i] == 0x7f or b[i] == 0x08) { if (number_len > 0) { number_len -= 1; - if (number_len > 0) { - const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; - if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { - selected_idx = parsed - 1; - try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selected_idx); + if (parsedDisplayedIndex(number_buf[0..number_len], total_accounts)) |displayed_idx| { + if (selectableIndexForDisplayedAccount(&rows, displayed_idx)) |selectable_idx| { + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selectable_idx); } } } @@ -2083,10 +2172,10 @@ pub fn selectAccountWithLiveUpdates( if (number_len < number_buf.len) { number_buf[number_len] = b[i]; number_len += 1; - const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; - if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { - selected_idx = parsed - 1; - try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selected_idx); + if (parsedDisplayedIndex(number_buf[0..number_len], total_accounts)) |displayed_idx| { + if (selectableIndexForDisplayedAccount(&rows, displayed_idx)) |selectable_idx| { + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selectable_idx); + } } } continue; @@ -2095,122 +2184,913 @@ pub fn selectAccountWithLiveUpdates( } } -pub fn selectAccountFromIndices(allocator: std.mem.Allocator, reg: *registry.Registry, indices: []const usize) !?[]const u8 { - return selectAccountFromIndicesWithUsageOverrides(allocator, reg, indices, null); -} - -pub fn selectAccountFromIndicesWithUsageOverrides( +pub fn viewAccountsWithLiveUpdates( allocator: std.mem.Allocator, - reg: *registry.Registry, - indices: []const usize, - usage_overrides: ?[]const ?[]const u8, -) !?[]const u8 { - if (indices.len == 0) return null; - if (indices.len == 1) return reg.accounts.items[indices[0]].account_key; - if (shouldUseNumberedSwitchSelector( - comptime builtin.os.tag == .windows, - std.Io.File.stdin().isTty(app_runtime.io()) catch false, - std.Io.File.stdout().isTty(app_runtime.io()) catch false, - )) { - return selectWithNumbersFromIndices(allocator, reg, indices, usage_overrides); - } - return try selectInteractiveFromIndices(allocator, reg, indices, usage_overrides); -} + initial_display: OwnedSwitchSelectionDisplay, + controller: SwitchLiveController, +) !void { + var current_display = initial_display; + defer current_display.deinit(allocator); -pub fn shouldUseNumberedSwitchSelector(is_windows: bool, stdin_is_tty: bool, stdout_is_tty: bool) bool { - _ = is_windows; - return !stdin_is_tty or !stdout_is_tty; -} + var tui = try TuiSession.init(); + defer tui.deinit(); -pub fn selectAccountsToRemove(allocator: std.mem.Allocator, reg: *registry.Registry) !?[]usize { - return selectAccountsToRemoveWithUsageOverrides(allocator, reg, null); -} + const out = tui.out(); + const use_color = try tui.output.isTty(app_runtime.io()); + const ui_tick_ms: i32 = 1000; -pub fn selectAccountsToRemoveWithUsageOverrides( - allocator: std.mem.Allocator, - reg: *registry.Registry, - usage_overrides: ?[]const ?[]const u8, -) !?[]usize { - if (shouldUseNumberedRemoveSelector( - comptime builtin.os.tag == .windows, - std.Io.File.stdin().isTty(app_runtime.io()) catch false, - std.Io.File.stdout().isTty(app_runtime.io()) catch false, - )) { - return selectRemoveWithNumbers(allocator, reg, usage_overrides); - } - return try selectRemoveInteractive(allocator, reg, usage_overrides); -} + while (true) { + if (try controller.maybe_take_updated_display(controller.context)) |updated| { + current_display.deinit(allocator); + current_display = updated; + } -pub fn shouldUseNumberedRemoveSelector(is_windows: bool, stdin_is_tty: bool, stdout_is_tty: bool) bool { - _ = is_windows; - return !stdin_is_tty or !stdout_is_tty; -} + var rows = try buildSwitchRowsWithUsageOverrides(allocator, ¤t_display.reg, current_display.usage_overrides); + defer rows.deinit(allocator); + const status_line = try controller.build_status_line(controller.context, allocator, current_display.borrowed()); + defer allocator.free(status_line); -fn isQuitInput(input: []const u8) bool { - return input.len == 1 and (input[0] == 'q' or input[0] == 'Q'); -} + try tui.resetFrame(); + try renderListScreen( + out, + ¤t_display.reg, + rows.items, + @max(@as(usize, 2), indexWidth(rows.selectable_row_indices.len)), + rows.widths, + use_color, + status_line, + ); + try out.flush(); -fn isQuitKey(key: u8) bool { - return key == 'q' or key == 'Q'; -} + switch (try pollTuiInput(tui.input, ui_tick_ms, tui_poll_error_mask)) { + .timeout => { + try controller.maybe_start_refresh(controller.context); + continue; + }, + .closed => return, + .ready => {}, + } -fn activeSelectableIndex(rows: *const SwitchRows) ?usize { - for (rows.selectable_row_indices, 0..) |row_idx, pos| { - if (rows.items[row_idx].is_active) return pos; - } - return null; -} + if (comptime builtin.os.tag == .windows) { + switch (try tui.readWindowsKey()) { + .quit => return, + .redraw => continue, + else => continue, + } + } -fn accountIdForSelectable(rows: *const SwitchRows, reg: *registry.Registry, selectable_idx: usize) []const u8 { - const row_idx = rows.selectable_row_indices[selectable_idx]; - const account_idx = rows.items[row_idx].account_index.?; - return reg.accounts.items[account_idx].account_key; + var b: [8]u8 = undefined; + const n = try tui.read(&b); + if (n == 0) return; + + var i: usize = 0; + while (i < n) : (i += 1) { + if (b[i] == 0x1b) { + const escape = try readTuiEscapeAction( + tui.input, + b[i + 1 .. n], + tui_poll_error_mask, + tui_escape_sequence_timeout_ms, + ); + switch (escape.action) { + .quit => return, + else => {}, + } + i += escape.buffered_bytes_consumed; + continue; + } + if (isQuitKey(b[i])) return; + } + } } -fn dupSelectedAccountKey( +pub fn runSwitchLiveActions( allocator: std.mem.Allocator, - rows: *const SwitchRows, - reg: *registry.Registry, - selectable_idx: usize, -) ![]const u8 { - return try allocator.dupe(u8, accountIdForSelectable(rows, reg, selectable_idx)); -} + initial_display: OwnedSwitchSelectionDisplay, + controller: SwitchLiveActionController, +) !void { + var current_display = initial_display; + defer current_display.deinit(allocator); -fn dupeOptionalAccountKey(allocator: std.mem.Allocator, account_key: ?[]const u8) !?[]const u8 { - return if (account_key) |value| try allocator.dupe(u8, value) else null; -} + var tui = try TuiSession.init(); + defer tui.deinit(); -fn accountIndexForSelectable(rows: *const SwitchRows, selectable_idx: usize) usize { - const row_idx = rows.selectable_row_indices[selectable_idx]; - return rows.items[row_idx].account_index.?; -} + const out = tui.out(); + const use_color = try tui.output.isTty(app_runtime.io()); + const ui_tick_ms: i32 = 1000; -fn selectableIndexForAccountKey( - rows: *const SwitchRows, - reg: *registry.Registry, - account_key: []const u8, -) ?usize { - for (rows.selectable_row_indices, 0..) |row_idx, selectable_idx| { - const account_idx = rows.items[row_idx].account_index orelse continue; - if (std.mem.eql(u8, reg.accounts.items[account_idx].account_key, account_key)) return selectable_idx; - } - return null; -} + var selected_account_key = if (current_display.reg.active_account_key) |key| + try allocator.dupe(u8, key) + else + null; + defer if (selected_account_key) |key| allocator.free(key); -fn replaceSelectedAccountKeyForSelectable( - allocator: std.mem.Allocator, - selected_account_key: *?[]u8, - rows: *const SwitchRows, - reg: *registry.Registry, - selectable_idx: usize, -) !void { - const next_key = try allocator.dupe(u8, accountIdForSelectable(rows, reg, selectable_idx)); - if (selected_account_key.*) |current_key| allocator.free(current_key); - selected_account_key.* = next_key; -} + var action_message: ?[]u8 = null; + defer if (action_message) |message| allocator.free(message); -fn selectWithNumbers( - allocator: std.mem.Allocator, + var number_buf: [8]u8 = undefined; + var number_len: usize = 0; + + while (true) { + if (try controller.refresh.maybe_take_updated_display(controller.refresh.context)) |updated| { + current_display.deinit(allocator); + current_display = updated; + } + + const borrowed = current_display.borrowed(); + var rows = try buildSwitchRowsWithUsageOverrides(allocator, borrowed.reg, borrowed.usage_overrides); + defer rows.deinit(allocator); + try filterErroredRowsFromSelectableIndices(allocator, &rows); + const total_accounts = accountRowCount(rows.items); + + var selected_idx: ?usize = null; + if (rows.selectable_row_indices.len != 0) { + selected_idx = if (selected_account_key) |key| + selectableIndexForAccountKey(&rows, borrowed.reg, key) orelse activeSelectableIndex(&rows) orelse 0 + else + activeSelectableIndex(&rows) orelse 0; + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selected_idx.?); + } + + const status_line = try controller.refresh.build_status_line(controller.refresh.context, allocator, borrowed); + defer allocator.free(status_line); + const selected_display_idx = selectedDisplayIndexForRender(&rows, selected_idx, number_buf[0..number_len]); + + try tui.resetFrame(); + try renderSwitchScreen( + out, + borrowed.reg, + rows.items, + @max(@as(usize, 2), indexWidth(total_accounts)), + rows.widths, + selected_display_idx, + use_color, + status_line, + action_message orelse "", + number_buf[0..number_len], + ); + try out.flush(); + + switch (try pollTuiInput(tui.input, ui_tick_ms, tui_poll_error_mask)) { + .timeout => { + try controller.refresh.maybe_start_refresh(controller.refresh.context); + continue; + }, + .closed => return, + .ready => {}, + } + + if (comptime builtin.os.tag == .windows) { + switch (try tui.readWindowsKey()) { + .move_up => { + if (selected_idx) |idx| { + if (idx > 0) { + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, idx - 1); + number_len = 0; + } + } + }, + .move_down => { + if (selected_idx) |idx| { + if (idx + 1 < rows.selectable_row_indices.len) { + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, idx + 1); + number_len = 0; + } + } + }, + .enter => { + const target_key = if (parsedDisplayedIndex(number_buf[0..number_len], total_accounts)) |displayed_idx| + try allocator.dupe(u8, accountIdForDisplayedAccount(&rows, borrowed.reg, displayed_idx) orelse continue) + else if (selected_idx) |idx| + try accountKeyForSelectableAlloc(allocator, &rows, borrowed.reg, idx) + else + continue; + defer allocator.free(target_key); + const outcome = controller.apply_selection(controller.refresh.context, allocator, target_key) catch |err| { + replaceOptionalOwnedString( + allocator, + &action_message, + try std.fmt.allocPrint(allocator, "Switch failed: {s}", .{@errorName(err)}), + ); + replaceOptionalOwnedString(allocator, &selected_account_key, try allocator.dupe(u8, target_key)); + number_len = 0; + continue; + }; + current_display.deinit(allocator); + current_display = outcome.updated_display; + replaceOptionalOwnedString(allocator, &action_message, outcome.action_message); + replaceOptionalOwnedString(allocator, &selected_account_key, try allocator.dupe(u8, target_key)); + number_len = 0; + }, + .quit => return, + .backspace => { + if (number_len > 0) { + number_len -= 1; + if (parsedDisplayedIndex(number_buf[0..number_len], total_accounts)) |displayed_idx| { + if (selectableIndexForDisplayedAccount(&rows, displayed_idx)) |selectable_idx| { + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selectable_idx); + } + } + } + }, + .redraw => continue, + .byte => |ch| { + if (isQuitKey(ch)) return; + if (ch == 'k') { + if (selected_idx) |idx| { + if (idx > 0) { + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, idx - 1); + number_len = 0; + } + } + continue; + } + if (ch == 'j') { + if (selected_idx) |idx| { + if (idx + 1 < rows.selectable_row_indices.len) { + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, idx + 1); + number_len = 0; + } + } + continue; + } + if (ch >= '0' and ch <= '9' and number_len < number_buf.len) { + number_buf[number_len] = ch; + number_len += 1; + if (parsedDisplayedIndex(number_buf[0..number_len], total_accounts)) |displayed_idx| { + if (selectableIndexForDisplayedAccount(&rows, displayed_idx)) |selectable_idx| { + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selectable_idx); + } + } + } + }, + } + continue; + } + + var b: [8]u8 = undefined; + const n = try tui.read(&b); + if (n == 0) return; + + var i: usize = 0; + while (i < n) : (i += 1) { + if (b[i] == 0x1b) { + const escape = try readTuiEscapeAction( + tui.input, + b[i + 1 .. n], + tui_poll_error_mask, + tui_escape_sequence_timeout_ms, + ); + switch (escape.action) { + .move_up => { + if (selected_idx) |idx| { + if (idx > 0) { + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, idx - 1); + number_len = 0; + } + } + }, + .move_down => { + if (selected_idx) |idx| { + if (idx + 1 < rows.selectable_row_indices.len) { + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, idx + 1); + number_len = 0; + } + } + }, + .quit => return, + .ignore => {}, + } + i += escape.buffered_bytes_consumed; + continue; + } + + if (b[i] == '\r' or b[i] == '\n') { + const target_key = if (parsedDisplayedIndex(number_buf[0..number_len], total_accounts)) |displayed_idx| + try allocator.dupe(u8, accountIdForDisplayedAccount(&rows, borrowed.reg, displayed_idx) orelse continue) + else if (selected_idx) |idx| + try accountKeyForSelectableAlloc(allocator, &rows, borrowed.reg, idx) + else + continue; + defer allocator.free(target_key); + const outcome = controller.apply_selection(controller.refresh.context, allocator, target_key) catch |err| { + replaceOptionalOwnedString( + allocator, + &action_message, + try std.fmt.allocPrint(allocator, "Switch failed: {s}", .{@errorName(err)}), + ); + replaceOptionalOwnedString(allocator, &selected_account_key, try allocator.dupe(u8, target_key)); + number_len = 0; + continue; + }; + current_display.deinit(allocator); + current_display = outcome.updated_display; + replaceOptionalOwnedString(allocator, &action_message, outcome.action_message); + replaceOptionalOwnedString(allocator, &selected_account_key, try allocator.dupe(u8, target_key)); + number_len = 0; + continue; + } + if (isQuitKey(b[i])) return; + + if (b[i] == 'k') { + if (selected_idx) |idx| { + if (idx > 0) { + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, idx - 1); + number_len = 0; + } + } + continue; + } + if (b[i] == 'j') { + if (selected_idx) |idx| { + if (idx + 1 < rows.selectable_row_indices.len) { + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, idx + 1); + number_len = 0; + } + } + continue; + } + if (b[i] == 0x7f or b[i] == 0x08) { + if (number_len > 0) { + number_len -= 1; + if (parsedDisplayedIndex(number_buf[0..number_len], total_accounts)) |displayed_idx| { + if (selectableIndexForDisplayedAccount(&rows, displayed_idx)) |selectable_idx| { + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selectable_idx); + } + } + } + continue; + } + if (b[i] >= '0' and b[i] <= '9') { + if (number_len < number_buf.len) { + number_buf[number_len] = b[i]; + number_len += 1; + if (parsedDisplayedIndex(number_buf[0..number_len], total_accounts)) |displayed_idx| { + if (selectableIndexForDisplayedAccount(&rows, displayed_idx)) |selectable_idx| { + try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selectable_idx); + } + } + } + continue; + } + } + } +} + +pub fn runRemoveLiveActions( + allocator: std.mem.Allocator, + initial_display: OwnedSwitchSelectionDisplay, + controller: RemoveLiveActionController, +) !void { + var current_display = initial_display; + defer current_display.deinit(allocator); + + var tui = try TuiSession.init(); + defer tui.deinit(); + + const out = tui.out(); + const use_color = try tui.output.isTty(app_runtime.io()); + const ui_tick_ms: i32 = 1000; + + var cursor_account_key: ?[]u8 = null; + defer if (cursor_account_key) |key| allocator.free(key); + + var checked_account_keys = std.ArrayList([]u8).empty; + defer { + clearOwnedAccountKeys(allocator, &checked_account_keys); + checked_account_keys.deinit(allocator); + } + + var action_message: ?[]u8 = null; + defer if (action_message) |message| allocator.free(message); + + var number_buf: [8]u8 = undefined; + var number_len: usize = 0; + + while (true) { + if (try controller.refresh.maybe_take_updated_display(controller.refresh.context)) |updated| { + current_display.deinit(allocator); + current_display = updated; + } + + const borrowed = current_display.borrowed(); + var rows = try buildSwitchRowsWithUsageOverrides(allocator, borrowed.reg, borrowed.usage_overrides); + defer rows.deinit(allocator); + + var cursor_idx: ?usize = null; + if (rows.selectable_row_indices.len != 0) { + cursor_idx = if (cursor_account_key) |key| + selectableIndexForAccountKey(&rows, borrowed.reg, key) orelse activeSelectableIndex(&rows) orelse 0 + else + activeSelectableIndex(&rows) orelse 0; + try replaceSelectedAccountKeyForSelectable(allocator, &cursor_account_key, &rows, borrowed.reg, cursor_idx.?); + } + + const checked_flags = try allocator.alloc(bool, rows.selectable_row_indices.len); + defer allocator.free(checked_flags); + for (checked_flags, 0..) |*flag, selectable_idx| { + flag.* = containsOwnedAccountKey(&checked_account_keys, accountIdForSelectable(&rows, borrowed.reg, selectable_idx)); + } + + const status_line = try controller.refresh.build_status_line(controller.refresh.context, allocator, borrowed); + defer allocator.free(status_line); + + try tui.resetFrame(); + try renderRemoveScreen( + out, + borrowed.reg, + rows.items, + @max(@as(usize, 2), indexWidth(rows.selectable_row_indices.len)), + rows.widths, + cursor_idx, + checked_flags, + use_color, + status_line, + action_message orelse "", + number_buf[0..number_len], + ); + try out.flush(); + + switch (try pollTuiInput(tui.input, ui_tick_ms, tui_poll_error_mask)) { + .timeout => { + try controller.refresh.maybe_start_refresh(controller.refresh.context); + continue; + }, + .closed => return, + .ready => {}, + } + + if (comptime builtin.os.tag == .windows) { + switch (try tui.readWindowsKey()) { + .move_up => { + if (cursor_idx) |idx| { + if (idx > 0) { + try replaceSelectedAccountKeyForSelectable(allocator, &cursor_account_key, &rows, borrowed.reg, idx - 1); + number_len = 0; + } + } + }, + .move_down => { + if (cursor_idx) |idx| { + if (idx + 1 < rows.selectable_row_indices.len) { + try replaceSelectedAccountKeyForSelectable(allocator, &cursor_account_key, &rows, borrowed.reg, idx + 1); + number_len = 0; + } + } + }, + .enter => { + if (checked_account_keys.items.len == 0) { + replaceOptionalOwnedString(allocator, &action_message, try allocator.dupe(u8, "No accounts selected")); + continue; + } + const selected_keys = try allocator.alloc([]const u8, checked_account_keys.items.len); + defer allocator.free(selected_keys); + for (checked_account_keys.items, 0..) |key, idx| selected_keys[idx] = key; + const outcome = controller.apply_selection(controller.refresh.context, allocator, selected_keys) catch |err| { + replaceOptionalOwnedString( + allocator, + &action_message, + try std.fmt.allocPrint(allocator, "Delete failed: {s}", .{@errorName(err)}), + ); + continue; + }; + clearOwnedAccountKeys(allocator, &checked_account_keys); + current_display.deinit(allocator); + current_display = outcome.updated_display; + replaceOptionalOwnedString(allocator, &action_message, outcome.action_message); + number_len = 0; + }, + .quit => return, + .backspace => { + if (number_len > 0) { + number_len -= 1; + if (number_len > 0 and rows.selectable_row_indices.len != 0) { + const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; + if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { + try replaceSelectedAccountKeyForSelectable(allocator, &cursor_account_key, &rows, borrowed.reg, parsed - 1); + } + } + } + }, + .redraw => continue, + .byte => |ch| { + if (isQuitKey(ch)) return; + if (ch == 'k') { + if (cursor_idx) |idx| { + if (idx > 0) { + try replaceSelectedAccountKeyForSelectable(allocator, &cursor_account_key, &rows, borrowed.reg, idx - 1); + number_len = 0; + } + } + continue; + } + if (ch == 'j') { + if (cursor_idx) |idx| { + if (idx + 1 < rows.selectable_row_indices.len) { + try replaceSelectedAccountKeyForSelectable(allocator, &cursor_account_key, &rows, borrowed.reg, idx + 1); + number_len = 0; + } + } + continue; + } + if (ch == ' ') { + if (cursor_idx) |idx| { + try toggleOwnedAccountKey(allocator, &checked_account_keys, accountIdForSelectable(&rows, borrowed.reg, idx)); + number_len = 0; + } + continue; + } + if (ch >= '0' and ch <= '9' and number_len < number_buf.len) { + number_buf[number_len] = ch; + number_len += 1; + if (rows.selectable_row_indices.len != 0) { + const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; + if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { + try replaceSelectedAccountKeyForSelectable(allocator, &cursor_account_key, &rows, borrowed.reg, parsed - 1); + } + } + } + }, + } + continue; + } + + var b: [8]u8 = undefined; + const n = try tui.read(&b); + if (n == 0) return; + + var i: usize = 0; + while (i < n) : (i += 1) { + if (b[i] == 0x1b) { + const escape = try readTuiEscapeAction( + tui.input, + b[i + 1 .. n], + tui_poll_error_mask, + tui_escape_sequence_timeout_ms, + ); + switch (escape.action) { + .move_up => { + if (cursor_idx) |idx| { + if (idx > 0) { + try replaceSelectedAccountKeyForSelectable(allocator, &cursor_account_key, &rows, borrowed.reg, idx - 1); + number_len = 0; + } + } + }, + .move_down => { + if (cursor_idx) |idx| { + if (idx + 1 < rows.selectable_row_indices.len) { + try replaceSelectedAccountKeyForSelectable(allocator, &cursor_account_key, &rows, borrowed.reg, idx + 1); + number_len = 0; + } + } + }, + .quit => return, + .ignore => {}, + } + i += escape.buffered_bytes_consumed; + continue; + } + + if (b[i] == '\r' or b[i] == '\n') { + if (checked_account_keys.items.len == 0) { + replaceOptionalOwnedString(allocator, &action_message, try allocator.dupe(u8, "No accounts selected")); + continue; + } + const selected_keys = try allocator.alloc([]const u8, checked_account_keys.items.len); + defer allocator.free(selected_keys); + for (checked_account_keys.items, 0..) |key, idx| selected_keys[idx] = key; + const outcome = controller.apply_selection(controller.refresh.context, allocator, selected_keys) catch |err| { + replaceOptionalOwnedString( + allocator, + &action_message, + try std.fmt.allocPrint(allocator, "Delete failed: {s}", .{@errorName(err)}), + ); + continue; + }; + clearOwnedAccountKeys(allocator, &checked_account_keys); + current_display.deinit(allocator); + current_display = outcome.updated_display; + replaceOptionalOwnedString(allocator, &action_message, outcome.action_message); + number_len = 0; + continue; + } + if (isQuitKey(b[i])) return; + if (b[i] == 'k') { + if (cursor_idx) |idx| { + if (idx > 0) { + try replaceSelectedAccountKeyForSelectable(allocator, &cursor_account_key, &rows, borrowed.reg, idx - 1); + number_len = 0; + } + } + continue; + } + if (b[i] == 'j') { + if (cursor_idx) |idx| { + if (idx + 1 < rows.selectable_row_indices.len) { + try replaceSelectedAccountKeyForSelectable(allocator, &cursor_account_key, &rows, borrowed.reg, idx + 1); + number_len = 0; + } + } + continue; + } + if (b[i] == ' ') { + if (cursor_idx) |idx| { + try toggleOwnedAccountKey(allocator, &checked_account_keys, accountIdForSelectable(&rows, borrowed.reg, idx)); + number_len = 0; + } + continue; + } + if (b[i] == 0x7f or b[i] == 0x08) { + if (number_len > 0) { + number_len -= 1; + if (number_len > 0 and rows.selectable_row_indices.len != 0) { + const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; + if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { + try replaceSelectedAccountKeyForSelectable(allocator, &cursor_account_key, &rows, borrowed.reg, parsed - 1); + } + } + } + continue; + } + if (b[i] >= '0' and b[i] <= '9') { + if (number_len < number_buf.len) { + number_buf[number_len] = b[i]; + number_len += 1; + if (rows.selectable_row_indices.len != 0) { + const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; + if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { + try replaceSelectedAccountKeyForSelectable(allocator, &cursor_account_key, &rows, borrowed.reg, parsed - 1); + } + } + } + continue; + } + } + } +} + +pub fn selectAccountFromIndices(allocator: std.mem.Allocator, reg: *registry.Registry, indices: []const usize) !?[]const u8 { + return selectAccountFromIndicesWithUsageOverrides(allocator, reg, indices, null); +} + +pub fn selectAccountFromIndicesWithUsageOverrides( + allocator: std.mem.Allocator, + reg: *registry.Registry, + indices: []const usize, + usage_overrides: ?[]const ?[]const u8, +) !?[]const u8 { + if (indices.len == 0) return null; + if (indices.len == 1) return reg.accounts.items[indices[0]].account_key; + if (shouldUseNumberedSwitchSelector( + comptime builtin.os.tag == .windows, + std.Io.File.stdin().isTty(app_runtime.io()) catch false, + std.Io.File.stdout().isTty(app_runtime.io()) catch false, + )) { + return selectWithNumbersFromIndices(allocator, reg, indices, usage_overrides); + } + return try selectInteractiveFromIndices(allocator, reg, indices, usage_overrides); +} + +pub fn shouldUseNumberedSwitchSelector(is_windows: bool, stdin_is_tty: bool, stdout_is_tty: bool) bool { + _ = is_windows; + return !stdin_is_tty or !stdout_is_tty; +} + +pub fn selectAccountsToRemove(allocator: std.mem.Allocator, reg: *registry.Registry) !?[]usize { + return selectAccountsToRemoveWithUsageOverrides(allocator, reg, null); +} + +pub fn selectAccountsToRemoveWithUsageOverrides( + allocator: std.mem.Allocator, + reg: *registry.Registry, + usage_overrides: ?[]const ?[]const u8, +) !?[]usize { + if (shouldUseNumberedRemoveSelector( + comptime builtin.os.tag == .windows, + std.Io.File.stdin().isTty(app_runtime.io()) catch false, + std.Io.File.stdout().isTty(app_runtime.io()) catch false, + )) { + return selectRemoveWithNumbers(allocator, reg, usage_overrides); + } + return try selectRemoveInteractive(allocator, reg, usage_overrides); +} + +pub fn shouldUseNumberedRemoveSelector(is_windows: bool, stdin_is_tty: bool, stdout_is_tty: bool) bool { + _ = is_windows; + return !stdin_is_tty or !stdout_is_tty; +} + +fn isQuitInput(input: []const u8) bool { + return input.len == 1 and (input[0] == 'q' or input[0] == 'Q'); +} + +fn isQuitKey(key: u8) bool { + return key == 'q' or key == 'Q'; +} + +fn activeSelectableIndex(rows: *const SwitchRows) ?usize { + for (rows.selectable_row_indices, 0..) |row_idx, pos| { + if (rows.items[row_idx].is_active) return pos; + } + return null; +} + +fn accountIdForSelectable(rows: *const SwitchRows, reg: *registry.Registry, selectable_idx: usize) []const u8 { + const row_idx = rows.selectable_row_indices[selectable_idx]; + const account_idx = rows.items[row_idx].account_index.?; + return reg.accounts.items[account_idx].account_key; +} + +fn accountRowCount(rows: []const SwitchRow) usize { + var count: usize = 0; + for (rows) |row| { + if (!row.is_header) count += 1; + } + return count; +} + +fn rowIndexForDisplayedAccount(rows: []const SwitchRow, displayed_idx: usize) ?usize { + var current: usize = 0; + for (rows, 0..) |row, row_idx| { + if (row.is_header) continue; + if (current == displayed_idx) return row_idx; + current += 1; + } + return null; +} + +fn displayedIndexForRowIndex(rows: []const SwitchRow, row_idx: usize) ?usize { + if (row_idx >= rows.len or rows[row_idx].is_header) return null; + var current: usize = 0; + for (rows, 0..) |row, idx| { + if (row.is_header) continue; + if (idx == row_idx) return current; + current += 1; + } + return null; +} + +fn displayedIndexForSelectable(rows: *const SwitchRows, selectable_idx: usize) ?usize { + if (selectable_idx >= rows.selectable_row_indices.len) return null; + return displayedIndexForRowIndex(rows.items, rows.selectable_row_indices[selectable_idx]); +} + +fn selectableIndexForDisplayedAccount(rows: *const SwitchRows, displayed_idx: usize) ?usize { + const row_idx = rowIndexForDisplayedAccount(rows.items, displayed_idx) orelse return null; + for (rows.selectable_row_indices, 0..) |selectable_row_idx, selectable_idx| { + if (selectable_row_idx == row_idx) return selectable_idx; + } + return null; +} + +fn accountIdForDisplayedAccount( + rows: *const SwitchRows, + reg: *registry.Registry, + displayed_idx: usize, +) ?[]const u8 { + const row_idx = rowIndexForDisplayedAccount(rows.items, displayed_idx) orelse return null; + const account_idx = rows.items[row_idx].account_index orelse return null; + return reg.accounts.items[account_idx].account_key; +} + +fn dupSelectedAccountKeyForDisplayedAccount( + allocator: std.mem.Allocator, + rows: *const SwitchRows, + reg: *registry.Registry, + displayed_idx: usize, +) !?[]const u8 { + const account_key = accountIdForDisplayedAccount(rows, reg, displayed_idx) orelse return null; + return try allocator.dupe(u8, account_key); +} + +fn parsedDisplayedIndex(number_input: []const u8, total_accounts: usize) ?usize { + if (number_input.len == 0) return null; + const parsed = std.fmt.parseInt(usize, number_input, 10) catch return null; + if (parsed == 0 or parsed > total_accounts) return null; + return parsed - 1; +} + +fn selectedDisplayIndexForRender( + rows: *const SwitchRows, + selected_selectable_idx: ?usize, + number_input: []const u8, +) ?usize { + if (parsedDisplayedIndex(number_input, accountRowCount(rows.items))) |displayed_idx| { + return displayed_idx; + } + if (selected_selectable_idx) |selectable_idx| { + return displayedIndexForSelectable(rows, selectable_idx); + } + return null; +} + +fn dupSelectedAccountKey( + allocator: std.mem.Allocator, + rows: *const SwitchRows, + reg: *registry.Registry, + selectable_idx: usize, +) ![]const u8 { + return try allocator.dupe(u8, accountIdForSelectable(rows, reg, selectable_idx)); +} + +fn dupeOptionalAccountKey(allocator: std.mem.Allocator, account_key: ?[]const u8) !?[]const u8 { + return if (account_key) |value| try allocator.dupe(u8, value) else null; +} + +fn accountIndexForSelectable(rows: *const SwitchRows, selectable_idx: usize) usize { + const row_idx = rows.selectable_row_indices[selectable_idx]; + return rows.items[row_idx].account_index.?; +} + +fn selectableIndexForAccountKey( + rows: *const SwitchRows, + reg: *registry.Registry, + account_key: []const u8, +) ?usize { + for (rows.selectable_row_indices, 0..) |row_idx, selectable_idx| { + const account_idx = rows.items[row_idx].account_index orelse continue; + if (std.mem.eql(u8, reg.accounts.items[account_idx].account_key, account_key)) return selectable_idx; + } + return null; +} + +fn replaceSelectedAccountKeyForSelectable( + allocator: std.mem.Allocator, + selected_account_key: *?[]u8, + rows: *const SwitchRows, + reg: *registry.Registry, + selectable_idx: usize, +) !void { + const next_key = try allocator.dupe(u8, accountIdForSelectable(rows, reg, selectable_idx)); + if (selected_account_key.*) |current_key| allocator.free(current_key); + selected_account_key.* = next_key; +} + +fn replaceOptionalOwnedString( + allocator: std.mem.Allocator, + target: *?[]u8, + next: ?[]u8, +) void { + if (target.*) |current| allocator.free(current); + target.* = next; +} + +fn accountKeyForSelectableAlloc( + allocator: std.mem.Allocator, + rows: *const SwitchRows, + reg: *registry.Registry, + selectable_idx: usize, +) ![]u8 { + return try allocator.dupe(u8, accountIdForSelectable(rows, reg, selectable_idx)); +} + +fn firstSelectableAccountKeyAlloc( + allocator: std.mem.Allocator, + rows: *const SwitchRows, + reg: *registry.Registry, +) !?[]u8 { + if (rows.selectable_row_indices.len == 0) return null; + return try accountKeyForSelectableAlloc(allocator, rows, reg, 0); +} + +fn removeOwnedAccountKey( + allocator: std.mem.Allocator, + keys: *std.ArrayList([]u8), + account_key: []const u8, +) bool { + for (keys.items, 0..) |key, idx| { + if (!std.mem.eql(u8, key, account_key)) continue; + allocator.free(key); + _ = keys.orderedRemove(idx); + return true; + } + return false; +} + +fn containsOwnedAccountKey(keys: *const std.ArrayList([]u8), account_key: []const u8) bool { + for (keys.items) |key| { + if (std.mem.eql(u8, key, account_key)) return true; + } + return false; +} + +fn toggleOwnedAccountKey( + allocator: std.mem.Allocator, + keys: *std.ArrayList([]u8), + account_key: []const u8, +) !void { + if (removeOwnedAccountKey(allocator, keys, account_key)) return; + try keys.append(allocator, try allocator.dupe(u8, account_key)); +} + +fn clearOwnedAccountKeys(allocator: std.mem.Allocator, keys: *std.ArrayList([]u8)) void { + for (keys.items) |key| allocator.free(key); + keys.clearRetainingCapacity(); +} + +fn selectWithNumbers( + allocator: std.mem.Allocator, reg: *registry.Registry, usage_overrides: ?[]const ?[]const u8, ) !?[]const u8 { @@ -2221,14 +3101,16 @@ fn selectWithNumbers( var rows = try buildSwitchRowsWithUsageOverrides(allocator, reg, usage_overrides); defer rows.deinit(allocator); try filterErroredRowsFromSelectableIndices(allocator, &rows); - if (rows.selectable_row_indices.len == 0) return null; + const total_accounts = accountRowCount(rows.items); + if (total_accounts == 0) return null; const use_color = colorEnabled(); const active_idx = activeSelectableIndex(&rows); - const idx_width = @max(@as(usize, 2), indexWidth(rows.selectable_row_indices.len)); + const idx_width = @max(@as(usize, 2), indexWidth(total_accounts)); const widths = rows.widths; + const active_display_idx = if (active_idx) |idx| displayedIndexForSelectable(&rows, idx) else null; try out.writeAll("Select account to activate:\n\n"); - try renderSwitchList(out, reg, rows.items, idx_width, widths, active_idx, use_color); + try renderSwitchList(out, reg, rows.items, idx_width, widths, active_display_idx, use_color); try out.writeAll("Select account number (or q to quit): "); try out.flush(); @@ -2240,9 +3122,8 @@ fn selectWithNumbers( return null; } if (isQuitInput(line)) return null; - const idx = std.fmt.parseInt(usize, line, 10) catch return null; - if (idx == 0 or idx > rows.selectable_row_indices.len) return null; - return accountIdForSelectable(&rows, reg, idx - 1); + const displayed_idx = parsedDisplayedIndex(line, total_accounts) orelse return null; + return accountIdForDisplayedAccount(&rows, reg, displayed_idx); } fn selectWithNumbersFromIndices( @@ -2259,14 +3140,16 @@ fn selectWithNumbersFromIndices( var rows = try buildSwitchRowsFromIndicesWithUsageOverrides(allocator, reg, indices, usage_overrides); defer rows.deinit(allocator); try filterErroredRowsFromSelectableIndices(allocator, &rows); - if (rows.selectable_row_indices.len == 0) return null; + const total_accounts = accountRowCount(rows.items); + if (total_accounts == 0) return null; const use_color = colorEnabled(); const active_idx = activeSelectableIndex(&rows); - const idx_width = @max(@as(usize, 2), indexWidth(rows.selectable_row_indices.len)); + const idx_width = @max(@as(usize, 2), indexWidth(total_accounts)); const widths = rows.widths; + const active_display_idx = if (active_idx) |idx| displayedIndexForSelectable(&rows, idx) else null; try out.writeAll("Select account to activate:\n\n"); - try renderSwitchList(out, reg, rows.items, idx_width, widths, active_idx, use_color); + try renderSwitchList(out, reg, rows.items, idx_width, widths, active_display_idx, use_color); try out.writeAll("Select account number (or q to quit): "); try out.flush(); @@ -2278,9 +3161,8 @@ fn selectWithNumbersFromIndices( return null; } if (isQuitInput(line)) return null; - const idx = std.fmt.parseInt(usize, line, 10) catch return null; - if (idx == 0 or idx > rows.selectable_row_indices.len) return null; - return accountIdForSelectable(&rows, reg, idx - 1); + const displayed_idx = parsedDisplayedIndex(line, total_accounts) orelse return null; + return accountIdForDisplayedAccount(&rows, reg, displayed_idx); } fn selectInteractiveFromIndices( @@ -2293,7 +3175,8 @@ fn selectInteractiveFromIndices( var rows = try buildSwitchRowsFromIndicesWithUsageOverrides(allocator, reg, indices, usage_overrides); defer rows.deinit(allocator); try filterErroredRowsFromSelectableIndices(allocator, &rows); - if (rows.selectable_row_indices.len == 0) return null; + const total_accounts = accountRowCount(rows.items); + if (total_accounts == 0) return null; var tui = try TuiSession.init(); defer tui.deinit(); @@ -2303,10 +3186,15 @@ fn selectInteractiveFromIndices( var number_buf: [8]u8 = undefined; var number_len: usize = 0; const use_color = try tui.output.isTty(app_runtime.io()); - const idx_width = @max(@as(usize, 2), indexWidth(rows.selectable_row_indices.len)); + const idx_width = @max(@as(usize, 2), indexWidth(total_accounts)); const widths = rows.widths; while (true) { + const selected_display_idx = selectedDisplayIndexForRender( + &rows, + if (rows.selectable_row_indices.len != 0) idx else null, + number_buf[0..number_len], + ); try tui.resetFrame(); try renderSwitchScreen( out, @@ -2314,9 +3202,10 @@ fn selectInteractiveFromIndices( rows.items, idx_width, widths, - idx, + selected_display_idx, use_color, "", + "", number_buf[0..number_len], ); try out.flush(); @@ -2324,34 +3213,31 @@ fn selectInteractiveFromIndices( if (comptime builtin.os.tag == .windows) { switch (try tui.readWindowsKey()) { .move_up => { - if (idx > 0) { + if (rows.selectable_row_indices.len != 0 and idx > 0) { idx -= 1; number_len = 0; } }, .move_down => { - if (idx + 1 < rows.selectable_row_indices.len) { + if (rows.selectable_row_indices.len != 0 and idx + 1 < rows.selectable_row_indices.len) { idx += 1; number_len = 0; } }, .enter => { - if (number_len > 0) { - const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; - if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { - return accountIdForSelectable(&rows, reg, parsed - 1); - } + if (parsedDisplayedIndex(number_buf[0..number_len], total_accounts)) |displayed_idx| { + return accountIdForDisplayedAccount(&rows, reg, displayed_idx); } + if (rows.selectable_row_indices.len == 0) return null; return accountIdForSelectable(&rows, reg, idx); }, .quit => return null, .backspace => { if (number_len > 0) { number_len -= 1; - if (number_len > 0) { - const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; - if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { - idx = parsed - 1; + if (parsedDisplayedIndex(number_buf[0..number_len], total_accounts)) |displayed_idx| { + if (selectableIndexForDisplayedAccount(&rows, displayed_idx)) |selectable_idx| { + idx = selectable_idx; } } } @@ -2359,12 +3245,12 @@ fn selectInteractiveFromIndices( .redraw => continue, .byte => |ch| { if (isQuitKey(ch)) return null; - if (ch == 'k' and idx > 0) { + if (ch == 'k' and rows.selectable_row_indices.len != 0 and idx > 0) { idx -= 1; number_len = 0; continue; } - if (ch == 'j' and idx + 1 < rows.selectable_row_indices.len) { + if (ch == 'j' and rows.selectable_row_indices.len != 0 and idx + 1 < rows.selectable_row_indices.len) { idx += 1; number_len = 0; continue; @@ -2372,9 +3258,10 @@ fn selectInteractiveFromIndices( if (ch >= '0' and ch <= '9' and number_len < number_buf.len) { number_buf[number_len] = ch; number_len += 1; - const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; - if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { - idx = parsed - 1; + if (parsedDisplayedIndex(number_buf[0..number_len], total_accounts)) |displayed_idx| { + if (selectableIndexForDisplayedAccount(&rows, displayed_idx)) |selectable_idx| { + idx = selectable_idx; + } } } }, @@ -2395,13 +3282,13 @@ fn selectInteractiveFromIndices( ); switch (escape.action) { .move_up => { - if (idx > 0) { + if (rows.selectable_row_indices.len != 0 and idx > 0) { idx -= 1; number_len = 0; } }, .move_down => { - if (idx + 1 < rows.selectable_row_indices.len) { + if (rows.selectable_row_indices.len != 0 and idx + 1 < rows.selectable_row_indices.len) { idx += 1; number_len = 0; } @@ -2414,22 +3301,20 @@ fn selectInteractiveFromIndices( } if (b[i] == '\r' or b[i] == '\n') { - if (number_len > 0) { - const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; - if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { - return accountIdForSelectable(&rows, reg, parsed - 1); - } + if (parsedDisplayedIndex(number_buf[0..number_len], total_accounts)) |displayed_idx| { + return accountIdForDisplayedAccount(&rows, reg, displayed_idx); } + if (rows.selectable_row_indices.len == 0) return null; return accountIdForSelectable(&rows, reg, idx); } if (isQuitKey(b[i])) return null; - if (b[i] == 'k' and idx > 0) { + if (b[i] == 'k' and rows.selectable_row_indices.len != 0 and idx > 0) { idx -= 1; number_len = 0; continue; } - if (b[i] == 'j' and idx + 1 < rows.selectable_row_indices.len) { + if (b[i] == 'j' and rows.selectable_row_indices.len != 0 and idx + 1 < rows.selectable_row_indices.len) { idx += 1; number_len = 0; continue; @@ -2437,10 +3322,9 @@ fn selectInteractiveFromIndices( if (b[i] == 0x7f or b[i] == 0x08) { if (number_len > 0) { number_len -= 1; - if (number_len > 0) { - const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; - if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { - idx = parsed - 1; + if (parsedDisplayedIndex(number_buf[0..number_len], total_accounts)) |displayed_idx| { + if (selectableIndexForDisplayedAccount(&rows, displayed_idx)) |selectable_idx| { + idx = selectable_idx; } } } @@ -2450,9 +3334,10 @@ fn selectInteractiveFromIndices( if (number_len < number_buf.len) { number_buf[number_len] = b[i]; number_len += 1; - const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; - if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { - idx = parsed - 1; + if (parsedDisplayedIndex(number_buf[0..number_len], total_accounts)) |displayed_idx| { + if (selectableIndexForDisplayedAccount(&rows, displayed_idx)) |selectable_idx| { + idx = selectable_idx; + } } } continue; @@ -2543,7 +3428,8 @@ fn selectInteractive( var rows = try buildSwitchRowsWithUsageOverrides(allocator, reg, usage_overrides); defer rows.deinit(allocator); try filterErroredRowsFromSelectableIndices(allocator, &rows); - if (rows.selectable_row_indices.len == 0) return null; + const total_accounts = accountRowCount(rows.items); + if (total_accounts == 0) return null; var tui = try TuiSession.init(); defer tui.deinit(); @@ -2553,10 +3439,15 @@ fn selectInteractive( var number_buf: [8]u8 = undefined; var number_len: usize = 0; const use_color = try tui.output.isTty(app_runtime.io()); - const idx_width = @max(@as(usize, 2), indexWidth(rows.selectable_row_indices.len)); + const idx_width = @max(@as(usize, 2), indexWidth(total_accounts)); const widths = rows.widths; while (true) { + const selected_display_idx = selectedDisplayIndexForRender( + &rows, + if (rows.selectable_row_indices.len != 0) idx else null, + number_buf[0..number_len], + ); try tui.resetFrame(); try renderSwitchScreen( out, @@ -2564,9 +3455,10 @@ fn selectInteractive( rows.items, idx_width, widths, - idx, + selected_display_idx, use_color, "", + "", number_buf[0..number_len], ); try out.flush(); @@ -2574,34 +3466,31 @@ fn selectInteractive( if (comptime builtin.os.tag == .windows) { switch (try tui.readWindowsKey()) { .move_up => { - if (idx > 0) { + if (rows.selectable_row_indices.len != 0 and idx > 0) { idx -= 1; number_len = 0; } }, .move_down => { - if (idx + 1 < rows.selectable_row_indices.len) { + if (rows.selectable_row_indices.len != 0 and idx + 1 < rows.selectable_row_indices.len) { idx += 1; number_len = 0; } }, .enter => { - if (number_len > 0) { - const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; - if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { - return accountIdForSelectable(&rows, reg, parsed - 1); - } + if (parsedDisplayedIndex(number_buf[0..number_len], total_accounts)) |displayed_idx| { + return accountIdForDisplayedAccount(&rows, reg, displayed_idx); } + if (rows.selectable_row_indices.len == 0) return null; return accountIdForSelectable(&rows, reg, idx); }, .quit => return null, .backspace => { if (number_len > 0) { number_len -= 1; - if (number_len > 0) { - const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; - if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { - idx = parsed - 1; + if (parsedDisplayedIndex(number_buf[0..number_len], total_accounts)) |displayed_idx| { + if (selectableIndexForDisplayedAccount(&rows, displayed_idx)) |selectable_idx| { + idx = selectable_idx; } } } @@ -2609,12 +3498,12 @@ fn selectInteractive( .redraw => continue, .byte => |ch| { if (isQuitKey(ch)) return null; - if (ch == 'k' and idx > 0) { + if (ch == 'k' and rows.selectable_row_indices.len != 0 and idx > 0) { idx -= 1; number_len = 0; continue; } - if (ch == 'j' and idx + 1 < rows.selectable_row_indices.len) { + if (ch == 'j' and rows.selectable_row_indices.len != 0 and idx + 1 < rows.selectable_row_indices.len) { idx += 1; number_len = 0; continue; @@ -2622,9 +3511,10 @@ fn selectInteractive( if (ch >= '0' and ch <= '9' and number_len < number_buf.len) { number_buf[number_len] = ch; number_len += 1; - const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; - if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { - idx = parsed - 1; + if (parsedDisplayedIndex(number_buf[0..number_len], total_accounts)) |displayed_idx| { + if (selectableIndexForDisplayedAccount(&rows, displayed_idx)) |selectable_idx| { + idx = selectable_idx; + } } } }, @@ -2645,13 +3535,13 @@ fn selectInteractive( ); switch (escape.action) { .move_up => { - if (idx > 0) { + if (rows.selectable_row_indices.len != 0 and idx > 0) { idx -= 1; number_len = 0; } }, .move_down => { - if (idx + 1 < rows.selectable_row_indices.len) { + if (rows.selectable_row_indices.len != 0 and idx + 1 < rows.selectable_row_indices.len) { idx += 1; number_len = 0; } @@ -2664,21 +3554,19 @@ fn selectInteractive( } if (b[i] == '\r' or b[i] == '\n') { - if (number_len > 0) { - const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; - if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { - return accountIdForSelectable(&rows, reg, parsed - 1); - } + if (parsedDisplayedIndex(number_buf[0..number_len], total_accounts)) |displayed_idx| { + return accountIdForDisplayedAccount(&rows, reg, displayed_idx); } + if (rows.selectable_row_indices.len == 0) return null; return accountIdForSelectable(&rows, reg, idx); } if (isQuitKey(b[i])) return null; - if (b[i] == 'k' and idx > 0) { + if (b[i] == 'k' and rows.selectable_row_indices.len != 0 and idx > 0) { idx -= 1; number_len = 0; continue; } - if (b[i] == 'j' and idx + 1 < rows.selectable_row_indices.len) { + if (b[i] == 'j' and rows.selectable_row_indices.len != 0 and idx + 1 < rows.selectable_row_indices.len) { idx += 1; number_len = 0; continue; @@ -2686,10 +3574,9 @@ fn selectInteractive( if (b[i] == 0x7f or b[i] == 0x08) { if (number_len > 0) { number_len -= 1; - if (number_len > 0) { - const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; - if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { - idx = parsed - 1; + if (parsedDisplayedIndex(number_buf[0..number_len], total_accounts)) |displayed_idx| { + if (selectableIndexForDisplayedAccount(&rows, displayed_idx)) |selectable_idx| { + idx = selectable_idx; } } } @@ -2699,9 +3586,10 @@ fn selectInteractive( if (number_len < number_buf.len) { number_buf[number_len] = b[i]; number_len += 1; - const parsed = std.fmt.parseInt(usize, number_buf[0..number_len], 10) catch 0; - if (parsed >= 1 and parsed <= rows.selectable_row_indices.len) { - idx = parsed - 1; + if (parsedDisplayedIndex(number_buf[0..number_len], total_accounts)) |displayed_idx| { + if (selectableIndexForDisplayedAccount(&rows, displayed_idx)) |selectable_idx| { + idx = selectable_idx; + } } } continue; @@ -2912,6 +3800,7 @@ fn renderSwitchScreen( selected: ?usize, use_color: bool, status_line: []const u8, + action_line: []const u8, number_input: []const u8, ) !void { try writeTuiPromptLine(out, "Select account to activate:", number_input); @@ -2925,6 +3814,65 @@ fn renderSwitchScreen( if (use_color) try out.writeAll(ansi.reset); } try writeSwitchTuiFooter(out, use_color); + if (action_line.len != 0) { + if (use_color) try out.writeAll(ansi.bold_green); + try out.writeAll(action_line); + try out.writeAll("\n"); + if (use_color) try out.writeAll(ansi.reset); + } +} + +fn renderListScreen( + out: *std.Io.Writer, + reg: *registry.Registry, + rows: []const SwitchRow, + idx_width: usize, + widths: SwitchWidths, + use_color: bool, + status_line: []const u8, +) !void { + try out.writeAll("Live account list:\n\n"); + try renderSwitchList(out, reg, rows, idx_width, widths, null, use_color); + try out.writeAll("\n"); + if (status_line.len != 0) { + if (use_color) try out.writeAll(ansi.dim); + try out.writeAll(status_line); + try out.writeAll("\n"); + if (use_color) try out.writeAll(ansi.reset); + } + try writeListTuiFooter(out, use_color); +} + +fn renderRemoveScreen( + out: *std.Io.Writer, + reg: *registry.Registry, + rows: []const SwitchRow, + idx_width: usize, + widths: SwitchWidths, + cursor: ?usize, + checked: []const bool, + use_color: bool, + status_line: []const u8, + action_line: []const u8, + number_input: []const u8, +) !void { + try writeTuiPromptLine(out, "Select accounts to delete:", number_input); + try out.writeAll("\n"); + try renderRemoveList(out, reg, rows, idx_width, widths, cursor, checked, use_color); + try out.writeAll("\n"); + if (status_line.len != 0) { + if (use_color) try out.writeAll(ansi.dim); + try out.writeAll(status_line); + try out.writeAll("\n"); + if (use_color) try out.writeAll(ansi.reset); + } + try writeRemoveTuiFooter(out, use_color); + if (action_line.len != 0) { + if (use_color) try out.writeAll(ansi.bold_green); + try out.writeAll(action_line); + try out.writeAll("\n"); + if (use_color) try out.writeAll(ansi.reset); + } } fn renderSwitchList( @@ -2953,7 +3901,7 @@ fn renderSwitchList( try writePadded(out, "LAST", widths.last); try out.writeAll("\n"); - var selectable_counter: usize = 0; + var displayed_counter: usize = 0; for (rows) |row| { if (row.is_header) { if (use_color) try out.writeAll(ansi.dim); @@ -2968,8 +3916,7 @@ fn renderSwitchList( continue; } - const is_selectable = !row.has_error; - const is_selected = is_selectable and selected != null and selected.? == selectable_counter; + const is_selected = selected != null and selected.? == displayed_counter; const is_active = row.is_active; if (use_color) { if (row.has_error) { @@ -2987,11 +3934,7 @@ fn renderSwitchList( } } try out.writeAll(if (is_selected) "> " else " "); - if (is_selectable) { - try writeIndexPadded(out, selectable_counter + 1, idx_width); - } else { - try writeRepeat(out, ' ', idx_width); - } + try writeIndexPadded(out, displayed_counter + 1, idx_width); try out.writeAll(" "); const indent: usize = @as(usize, row.depth) * 2; const indent_to_print: usize = @min(indent, widths.email); @@ -3010,7 +3953,7 @@ fn renderSwitchList( } try out.writeAll("\n"); if (use_color) try out.writeAll(ansi.reset); - if (is_selectable) selectable_counter += 1; + displayed_counter += 1; } } @@ -3601,7 +4544,7 @@ test "Scenario: Given usage overrides when selecting switch accounts then errore try std.testing.expect(std.mem.eql(u8, accountIdForSelectable(&rows, ®, 0), "user-1::acc-1")); } -test "Scenario: Given usage overrides when rendering switch list then errored rows do not show selection numbers" { +test "Scenario: Given usage overrides when rendering switch list then errored rows still show full display numbers" { const gpa = std.testing.allocator; var reg = makeTestRegistry(); defer reg.deinit(gpa); @@ -3620,10 +4563,40 @@ test "Scenario: Given usage overrides when rendering switch list then errored ro const output = writer.buffered(); try std.testing.expect(std.mem.indexOf(u8, output, "01 healthy@example.com") != null); - try std.testing.expect(std.mem.indexOf(u8, output, "02 failed@example.com") == null); + try std.testing.expect(std.mem.indexOf(u8, output, "02 ") != null); try std.testing.expect(std.mem.indexOf(u8, output, "failed@example.com") != null); } +test "Scenario: Given switch live feedback when rendering switch screen then the action message stays below the footer" { + const gpa = std.testing.allocator; + var reg = makeTestRegistry(); + defer reg.deinit(gpa); + + try appendTestAccount(gpa, ®, "user-1::acc-1", "healthy@example.com", "", .team); + var rows = try buildSwitchRows(gpa, ®); + defer rows.deinit(gpa); + + var buffer: [2048]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buffer); + try renderSwitchScreen( + &writer, + ®, + rows.items, + @max(@as(usize, 2), indexWidth(accountRowCount(rows.items))), + rows.widths, + 0, + false, + "Live refresh: api | Refresh in 9s", + "Switched to healthy@example.com", + "", + ); + + const output = writer.buffered(); + const footer_pos = std.mem.indexOf(u8, output, "Keys:") orelse return error.TestExpectedEqual; + const action_pos = std.mem.indexOf(u8, output, "Switched to healthy@example.com") orelse return error.TestExpectedEqual; + try std.testing.expect(action_pos > footer_pos); +} + test "Scenario: Given usage overrides when rendering remove list then failed rows show response status in both usage columns" { const gpa = std.testing.allocator; var reg = makeTestRegistry(); diff --git a/src/main.zig b/src/main.zig index 13fc262..2c6dc71 100644 --- a/src/main.zig +++ b/src/main.zig @@ -18,7 +18,6 @@ const disable_background_account_name_refresh_env = "CODEX_AUTH_DISABLE_BACKGROU const foreground_usage_refresh_concurrency: usize = 5; const switch_live_api_refresh_interval_ms: i64 = 30_000; const switch_live_local_refresh_interval_ms: i64 = 10_000; -const switch_live_stored_refresh_interval_ms: i64 = 10_000; fn getEnvMap(allocator: std.mem.Allocator) !std.process.Environ.Map { return try app_runtime.currentEnviron().createMap(allocator); @@ -224,6 +223,7 @@ fn runMain(init: std.process.Init.Minimal) !void { fn isHandledCliError(err: anyerror) bool { return err == error.AccountNotFound or err == error.CodexLoginFailed or + err == error.ListLiveRequiresTty or err == error.NodeJsRequired or err == error.SwitchSelectionRequiresTty or err == error.RemoveConfirmationUnavailable or @@ -246,7 +246,7 @@ pub const ForegroundUsageRefreshTarget = enum { }; pub fn shouldRefreshForegroundUsage(target: ForegroundUsageRefreshTarget) bool { - return target == .list or target == .switch_account; + return target == .list or target == .switch_account or target == .remove_account; } fn apiModeUsesApi(default_enabled: bool, api_mode: cli.ApiMode) bool { @@ -257,10 +257,6 @@ fn apiModeUsesApi(default_enabled: bool, api_mode: cli.ApiMode) bool { }; } -fn apiModeUsesStoredDataOnly(api_mode: cli.ApiMode) bool { - return api_mode == .skip_api; -} - fn shouldPreflightNodeForForegroundTargetWithApiEnabled( allocator: std.mem.Allocator, codex_home: []const u8, @@ -1549,15 +1545,53 @@ fn loadSingleFileImportAuthInfo( fn handleList(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.ListOptions) !void { if (isAccountNameRefreshOnlyMode()) return try runBackgroundAccountNameRefresh(allocator, codex_home, defaultAccountFetcher); + if (opts.live) { + const live_allocator = std.heap.smp_allocator; + const strict_refresh = opts.api_mode == .force_api; + const loaded = try loadSwitchSelectionDisplay( + live_allocator, + codex_home, + opts.api_mode, + .list, + strict_refresh, + ); + var initial_display: ?cli.OwnedSwitchSelectionDisplay = loaded.display; + errdefer if (initial_display) |*display| display.deinit(live_allocator); + + var runtime = SwitchLiveRuntime.init( + live_allocator, + codex_home, + .list, + opts.api_mode, + strict_refresh, + loaded.policy, + ); + defer runtime.deinit(); + + const controller: cli.SwitchLiveController = .{ + .context = @ptrCast(&runtime), + .maybe_start_refresh = switchLiveRuntimeMaybeStartRefresh, + .maybe_take_updated_display = switchLiveRuntimeMaybeTakeUpdatedDisplay, + .build_status_line = switchLiveRuntimeBuildStatusLine, + }; + + const transferred_display = initial_display.?; + initial_display = null; + cli.viewAccountsWithLiveUpdates(live_allocator, transferred_display, controller) catch |err| { + if (err == error.TuiRequiresTty) { + try cli.printListRequiresTtyError(); + return error.ListLiveRequiresTty; + } + return err; + }; + return; + } + var reg = try registry.loadRegistry(allocator, codex_home); defer reg.deinit(allocator); if (try registry.syncActiveAccountFromAuth(allocator, codex_home, ®)) { try registry.saveRegistry(allocator, codex_home, ®); } - if (apiModeUsesStoredDataOnly(opts.api_mode)) { - try format.printAccounts(®); - return; - } const usage_api_enabled = apiModeUsesApi(reg.api.usage, opts.api_mode); const account_api_enabled = apiModeUsesApi(reg.api.account, opts.api_mode); @@ -1667,6 +1701,7 @@ fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. try registry.saveRegistry(allocator, codex_home, ®); } std.debug.assert(opts.api_mode == .default); + std.debug.assert(!opts.live); var resolution = try resolveSwitchQueryLocally(allocator, ®, query); defer resolution.deinit(allocator); @@ -1696,44 +1731,74 @@ fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. return; } - const live_allocator = std.heap.smp_allocator; + if (!opts.live) { + var loaded = try loadSwitchSelectionDisplay( + allocator, + codex_home, + opts.api_mode, + .switch_account, + true, + ); + defer loaded.display.deinit(allocator); + + const selected_account_key = cli.selectAccountWithUsageOverrides( + allocator, + &loaded.display.reg, + loaded.display.usage_overrides, + ) catch |err| { + if (err == error.TuiRequiresTty) { + try cli.printSwitchRequiresTtyError(); + return error.SwitchSelectionRequiresTty; + } + return err; + }; + if (selected_account_key == null) return; + try registry.activateAccountByKey(allocator, codex_home, &loaded.display.reg, selected_account_key.?); + try registry.saveRegistry(allocator, codex_home, &loaded.display.reg); + return; + } - const loaded = try loadSwitchSelectionDisplay(live_allocator, codex_home, opts.api_mode); + const live_allocator = std.heap.smp_allocator; + const strict_refresh = opts.api_mode == .force_api; + const loaded = try loadSwitchSelectionDisplay( + live_allocator, + codex_home, + opts.api_mode, + .switch_account, + strict_refresh, + ); var initial_display: ?cli.OwnedSwitchSelectionDisplay = loaded.display; errdefer if (initial_display) |*display| display.deinit(live_allocator); - const selected_account_key = blk: { - var runtime = SwitchLiveRuntime.init(live_allocator, codex_home, opts.api_mode, loaded.policy); - defer runtime.deinit(); + var runtime = SwitchLiveRuntime.init( + live_allocator, + codex_home, + .switch_account, + opts.api_mode, + strict_refresh, + loaded.policy, + ); + defer runtime.deinit(); - const controller: cli.SwitchLiveController = .{ + const controller: cli.SwitchLiveActionController = .{ + .refresh = .{ .context = @ptrCast(&runtime), .maybe_start_refresh = switchLiveRuntimeMaybeStartRefresh, .maybe_take_updated_display = switchLiveRuntimeMaybeTakeUpdatedDisplay, .build_status_line = switchLiveRuntimeBuildStatusLine, - }; - - const transferred_display = initial_display.?; - initial_display = null; - break :blk cli.selectAccountWithLiveUpdates(live_allocator, transferred_display, controller) catch |err| { - if (err == error.TuiRequiresTty) { - try cli.printSwitchRequiresTtyError(); - return error.SwitchSelectionRequiresTty; - } - return err; - }; + }, + .apply_selection = switchLiveRuntimeApplySelection, }; - defer if (selected_account_key) |account_key| live_allocator.free(@constCast(account_key)); - if (selected_account_key == null) return; - - var reg = try registry.loadRegistry(live_allocator, codex_home); - defer reg.deinit(live_allocator); - if (try registry.syncActiveAccountFromAuth(live_allocator, codex_home, ®)) { - try registry.saveRegistry(live_allocator, codex_home, ®); - } - try registry.activateAccountByKey(live_allocator, codex_home, ®, selected_account_key.?); - try registry.saveRegistry(live_allocator, codex_home, ®); + const transferred_display = initial_display.?; + initial_display = null; + cli.runSwitchLiveActions(live_allocator, transferred_display, controller) catch |err| { + if (err == error.TuiRequiresTty) { + try cli.printSwitchRequiresTtyError(); + return error.SwitchSelectionRequiresTty; + } + return err; + }; } const SwitchLiveRefreshPolicy = struct { @@ -1751,7 +1816,9 @@ const SwitchLoadedDisplay = struct { const SwitchLiveRuntime = struct { allocator: std.mem.Allocator, codex_home: []const u8, + target: ForegroundUsageRefreshTarget, api_mode: cli.ApiMode, + strict_refresh: bool, io_impl: std.Io.Threaded, mutex: std.Io.Mutex = .init, refresh_task: ?std.Io.Future(void) = null, @@ -1768,7 +1835,9 @@ const SwitchLiveRuntime = struct { fn init( allocator: std.mem.Allocator, codex_home: []const u8, + target: ForegroundUsageRefreshTarget, api_mode: cli.ApiMode, + strict_refresh: bool, initial_policy: SwitchLiveRefreshPolicy, ) @This() { const io_impl = std.Io.Threaded.init(allocator, .{ @@ -1778,7 +1847,9 @@ const SwitchLiveRuntime = struct { return .{ .allocator = allocator, .codex_home = codex_home, + .target = target, .api_mode = api_mode, + .strict_refresh = strict_refresh, .io_impl = io_impl, .next_refresh_not_before_ms = now_ms + initial_policy.interval_ms, .refresh_interval_ms = initial_policy.interval_ms, @@ -1859,6 +1930,14 @@ const SwitchLiveRuntime = struct { return display; } + fn discardUpdatedDisplay(self: *@This()) void { + const io = self.io_impl.io(); + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + if (self.updated_display) |*display| display.deinit(self.allocator); + self.updated_display = null; + } + fn buildStatusLine(self: *@This(), allocator: std.mem.Allocator, display: cli.SwitchSelectionDisplay) ![]u8 { _ = display; const io = self.io_impl.io(); @@ -1866,7 +1945,7 @@ const SwitchLiveRuntime = struct { var in_flight = false; var next_refresh_not_before_ms: i64 = now_ms; - var mode_label: []const u8 = "stored"; + var mode_label: []const u8 = "local"; var refresh_error_name: ?[]u8 = null; self.mutex.lockUncancelable(io); @@ -1902,15 +1981,6 @@ const SwitchLiveRuntime = struct { }; fn switchLiveRefreshPolicy(reg: *const registry.Registry, api_mode: cli.ApiMode) SwitchLiveRefreshPolicy { - if (apiModeUsesStoredDataOnly(api_mode)) { - return .{ - .usage_api_enabled = false, - .account_api_enabled = false, - .interval_ms = switch_live_stored_refresh_interval_ms, - .label = "stored", - }; - } - const usage_api_enabled = apiModeUsesApi(reg.api.usage, api_mode); const account_api_enabled = apiModeUsesApi(reg.api.account, api_mode); if (usage_api_enabled or account_api_enabled) { @@ -2086,26 +2156,32 @@ fn takeOwnedSwitchSelectionDisplay( }; } -fn loadSwitchSelectionDisplay( +fn loadStoredSwitchSelectionDisplay( allocator: std.mem.Allocator, codex_home: []const u8, api_mode: cli.ApiMode, ) !SwitchLoadedDisplay { - if (apiModeUsesStoredDataOnly(api_mode)) { - var latest = try registry.loadRegistry(allocator, codex_home); - errdefer latest.deinit(allocator); - if (try registry.syncActiveAccountFromAuth(allocator, codex_home, &latest)) { - try registry.saveRegistry(allocator, codex_home, &latest); - } - return .{ - .display = .{ - .reg = latest, - .usage_overrides = try allocEmptySwitchUsageOverrides(allocator, latest.accounts.items.len), - }, - .policy = switchLiveRefreshPolicy(&latest, api_mode), - }; + var latest = try registry.loadRegistry(allocator, codex_home); + errdefer latest.deinit(allocator); + if (try registry.syncActiveAccountFromAuth(allocator, codex_home, &latest)) { + try registry.saveRegistry(allocator, codex_home, &latest); } + return .{ + .display = .{ + .reg = latest, + .usage_overrides = try allocEmptySwitchUsageOverrides(allocator, latest.accounts.items.len), + }, + .policy = switchLiveRefreshPolicy(&latest, api_mode), + }; +} +fn loadSwitchSelectionDisplay( + allocator: std.mem.Allocator, + codex_home: []const u8, + api_mode: cli.ApiMode, + target: ForegroundUsageRefreshTarget, + strict_refresh: bool, +) !SwitchLoadedDisplay { var base = try registry.loadRegistry(allocator, codex_home); defer base.deinit(allocator); @@ -2114,15 +2190,23 @@ fn loadSwitchSelectionDisplay( _ = try registry.syncActiveAccountFromAuth(allocator, codex_home, &refreshed); const initial_policy = switchLiveRefreshPolicy(&refreshed, api_mode); - try ensureForegroundNodeAvailableWithApiEnabled( + ensureForegroundNodeAvailableWithApiEnabled( allocator, codex_home, &refreshed, - .switch_account, + target, initial_policy.usage_api_enabled, initial_policy.account_api_enabled, - ); - var usage_state = try refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitAndDebugUsingApiEnabledAndPersist( + ) catch |err| switch (err) { + error.OutOfMemory => return err, + else => { + if (strict_refresh) return err; + refreshed.deinit(allocator); + return loadStoredSwitchSelectionDisplay(allocator, codex_home, api_mode); + }, + }; + + var usage_state = refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitAndDebugUsingApiEnabledAndPersist( allocator, codex_home, &refreshed, @@ -2133,17 +2217,33 @@ fn loadSwitchSelectionDisplay( initial_policy.usage_api_enabled, false, false, - ); + ) catch |err| switch (err) { + error.OutOfMemory => return err, + else => { + if (strict_refresh) return err; + refreshed.deinit(allocator); + return loadStoredSwitchSelectionDisplay(allocator, codex_home, api_mode); + }, + }; errdefer usage_state.deinit(allocator); - _ = try maybeRefreshForegroundAccountNamesWithAccountApiEnabledAndPersist( + + _ = maybeRefreshForegroundAccountNamesWithAccountApiEnabledAndPersist( allocator, codex_home, &refreshed, - .switch_account, + target, defaultAccountFetcher, initial_policy.account_api_enabled, false, - ); + ) catch |err| switch (err) { + error.OutOfMemory => return err, + else => { + if (strict_refresh) return err; + usage_state.deinit(allocator); + refreshed.deinit(allocator); + return loadStoredSwitchSelectionDisplay(allocator, codex_home, api_mode); + }, + }; var latest = try registry.loadRegistry(allocator, codex_home); errdefer latest.deinit(allocator); @@ -2175,7 +2275,13 @@ fn loadSwitchSelectionDisplay( fn runSwitchLiveRefreshRound(runtime: *SwitchLiveRuntime) void { const io = runtime.io_impl.io(); const started_ms = nowMilliseconds(); - const loaded = loadSwitchSelectionDisplay(runtime.allocator, runtime.codex_home, runtime.api_mode) catch |err| { + const loaded = loadSwitchSelectionDisplay( + runtime.allocator, + runtime.codex_home, + runtime.api_mode, + runtime.target, + runtime.strict_refresh, + ) catch |err| { const finished_ms = nowMilliseconds(); const error_name = runtime.allocator.dupe(u8, @errorName(err)) catch null; @@ -2225,6 +2331,198 @@ fn switchLiveRuntimeBuildStatusLine( return runtime.buildStatusLine(allocator, display); } +fn loadSwitchSelectionDisplayLenient( + allocator: std.mem.Allocator, + codex_home: []const u8, + api_mode: cli.ApiMode, + target: ForegroundUsageRefreshTarget, +) !SwitchLoadedDisplay { + return loadSwitchSelectionDisplay(allocator, codex_home, api_mode, target, false) catch |err| switch (err) { + error.OutOfMemory => return err, + else => try loadStoredSwitchSelectionDisplay(allocator, codex_home, api_mode), + }; +} + +fn accountLabelForKeyAlloc( + allocator: std.mem.Allocator, + reg: *registry.Registry, + account_key: []const u8, +) ![]u8 { + if (registry.findAccountIndexByAccountKey(reg, account_key)) |idx| { + return display_rows.buildPreferredAccountLabelAlloc( + allocator, + ®.accounts.items[idx], + reg.accounts.items[idx].email, + ); + } + return allocator.dupe(u8, account_key); +} + +fn buildRemoveSummaryMessageAlloc(allocator: std.mem.Allocator, labels: []const []const u8) ![]u8 { + var out: std.Io.Writer.Allocating = .init(allocator); + errdefer out.deinit(); + + try out.writer.print("Removed {d} account(s): ", .{labels.len}); + for (labels, 0..) |label, idx| { + if (idx != 0) try out.writer.writeAll(", "); + try out.writer.writeAll(label); + } + return try out.toOwnedSlice(); +} + +fn collectAccountIndicesByKeysAlloc( + allocator: std.mem.Allocator, + reg: *registry.Registry, + account_keys: []const []const u8, +) ![]usize { + var indices = std.ArrayList(usize).empty; + defer indices.deinit(allocator); + + for (reg.accounts.items, 0..) |rec, idx| { + for (account_keys) |account_key| { + if (!std.mem.eql(u8, rec.account_key, account_key)) continue; + try indices.append(allocator, idx); + break; + } + } + + return try indices.toOwnedSlice(allocator); +} + +fn removeSelectedAccountsAndPersist( + allocator: std.mem.Allocator, + codex_home: []const u8, + reg: *registry.Registry, + selected: []const usize, + selected_all: bool, +) !void { + const current_active_account_key = if (trackedActiveAccountKey(reg)) |key| + try allocator.dupe(u8, key) + else + null; + defer if (current_active_account_key) |key| allocator.free(key); + + var current_auth_state = try loadCurrentAuthState(allocator, codex_home); + defer current_auth_state.deinit(allocator); + + const active_removed = if (current_active_account_key) |key| + selectionContainsAccountKey(reg, selected, key) + else + false; + const allow_auth_file_update = if (current_active_account_key) |key| + active_removed and ((current_auth_state.syncable and current_auth_state.record_key != null and + std.mem.eql(u8, current_auth_state.record_key.?, key)) or current_auth_state.missing) + else if (current_auth_state.missing) + true + else if (selected_all) + current_auth_state.syncable and current_auth_state.record_key != null and + selectionContainsAccountKey(reg, selected, current_auth_state.record_key.?) + else + false; + + const replacement_account_key = if (active_removed) + try selectBestRemainingAccountKeyByUsageAlloc(allocator, reg, selected) + else + null; + defer if (replacement_account_key) |key| allocator.free(key); + + if (replacement_account_key) |key| { + if (allow_auth_file_update) { + try registry.replaceActiveAuthWithAccountByKey(allocator, codex_home, reg, key); + } else { + try registry.setActiveAccountKey(allocator, reg, key); + } + } + + try registry.removeAccounts(allocator, codex_home, reg, selected); + try reconcileActiveAuthAfterRemove(allocator, codex_home, reg, allow_auth_file_update); + try registry.saveRegistry(allocator, codex_home, reg); +} + +fn switchLiveRuntimeApplySelection( + context: *anyopaque, + allocator: std.mem.Allocator, + account_key: []const u8, +) !cli.LiveActionOutcome { + const runtime: *SwitchLiveRuntime = @ptrCast(@alignCast(context)); + runtime.awaitRefresh(); + runtime.discardUpdatedDisplay(); + + var reg = try registry.loadRegistry(allocator, runtime.codex_home); + defer reg.deinit(allocator); + if (try registry.syncActiveAccountFromAuth(allocator, runtime.codex_home, ®)) { + try registry.saveRegistry(allocator, runtime.codex_home, ®); + } + + try registry.activateAccountByKey(allocator, runtime.codex_home, ®, account_key); + try registry.saveRegistry(allocator, runtime.codex_home, ®); + + const label = try accountLabelForKeyAlloc(allocator, ®, account_key); + defer allocator.free(label); + + const loaded = try loadSwitchSelectionDisplayLenient( + allocator, + runtime.codex_home, + runtime.api_mode, + runtime.target, + ); + return .{ + .updated_display = loaded.display, + .action_message = try std.fmt.allocPrint(allocator, "Switched to {s}", .{label}), + }; +} + +fn removeLiveRuntimeApplySelection( + context: *anyopaque, + allocator: std.mem.Allocator, + account_keys: []const []const u8, +) !cli.LiveActionOutcome { + const runtime: *SwitchLiveRuntime = @ptrCast(@alignCast(context)); + runtime.awaitRefresh(); + runtime.discardUpdatedDisplay(); + + var reg = try registry.loadRegistry(allocator, runtime.codex_home); + defer reg.deinit(allocator); + if (try registry.syncActiveAccountFromAuth(allocator, runtime.codex_home, ®)) { + try registry.saveRegistry(allocator, runtime.codex_home, ®); + } + + const selected = try collectAccountIndicesByKeysAlloc(allocator, ®, account_keys); + defer allocator.free(selected); + + if (selected.len == 0) { + const loaded = try loadSwitchSelectionDisplayLenient( + allocator, + runtime.codex_home, + runtime.api_mode, + runtime.target, + ); + return .{ + .updated_display = loaded.display, + .action_message = try allocator.dupe(u8, "No matching accounts selected"), + }; + } + + var removed_labels = try cli.buildRemoveLabels(allocator, ®, selected); + defer { + freeOwnedStrings(allocator, removed_labels.items); + removed_labels.deinit(allocator); + } + + try removeSelectedAccountsAndPersist(allocator, runtime.codex_home, ®, selected, false); + + const loaded = try loadSwitchSelectionDisplayLenient( + allocator, + runtime.codex_home, + runtime.api_mode, + runtime.target, + ); + return .{ + .updated_display = loaded.display, + .action_message = try buildRemoveSummaryMessageAlloc(allocator, removed_labels.items), + }; +} + pub fn resolveSwitchQueryLocally( allocator: std.mem.Allocator, reg: *registry.Registry, @@ -2277,6 +2575,27 @@ pub fn findMatchingAccounts( return matches; } +fn findMatchingAccountsForRemove( + allocator: std.mem.Allocator, + reg: *registry.Registry, + query: []const u8, +) !std.ArrayList(usize) { + var matches = std.ArrayList(usize).empty; + for (reg.accounts.items, 0..) |*rec, idx| { + const matches_email = std.ascii.indexOfIgnoreCase(rec.email, query) != null; + const matches_alias = rec.alias.len != 0 and std.ascii.indexOfIgnoreCase(rec.alias, query) != null; + const matches_name = if (rec.account_name) |name| + name.len != 0 and std.ascii.indexOfIgnoreCase(name, query) != null + else + false; + const matches_key = std.ascii.indexOfIgnoreCase(rec.account_key, query) != null; + if (matches_email or matches_alias or matches_name or matches_key) { + try matches.append(allocator, idx); + } + } + return matches; +} + fn parseDisplayNumber(selector: []const u8) ?usize { if (selector.len == 0) return null; for (selector) |ch| { @@ -2398,26 +2717,81 @@ fn handleRemove(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. } const interactive_remove = !opts.all and opts.selectors.len == 0; - const usage_api_enabled = if (interactive_remove) apiModeUsesApi(false, opts.api_mode) else false; - const account_api_enabled = if (interactive_remove) apiModeUsesApi(false, opts.api_mode) else false; + if (interactive_remove and opts.live) { + const live_allocator = std.heap.smp_allocator; + const strict_refresh = opts.api_mode == .force_api; + const loaded = try loadSwitchSelectionDisplay( + live_allocator, + codex_home, + opts.api_mode, + .remove_account, + strict_refresh, + ); + var initial_display: ?cli.OwnedSwitchSelectionDisplay = loaded.display; + errdefer if (initial_display) |*display| display.deinit(live_allocator); + + var runtime = SwitchLiveRuntime.init( + live_allocator, + codex_home, + .remove_account, + opts.api_mode, + strict_refresh, + loaded.policy, + ); + defer runtime.deinit(); + + const controller: cli.RemoveLiveActionController = .{ + .refresh = .{ + .context = @ptrCast(&runtime), + .maybe_start_refresh = switchLiveRuntimeMaybeStartRefresh, + .maybe_take_updated_display = switchLiveRuntimeMaybeTakeUpdatedDisplay, + .build_status_line = switchLiveRuntimeBuildStatusLine, + }, + .apply_selection = removeLiveRuntimeApplySelection, + }; + + const transferred_display = initial_display.?; + initial_display = null; + cli.runRemoveLiveActions(live_allocator, transferred_display, controller) catch |err| { + if (err == error.TuiRequiresTty) { + try cli.printRemoveRequiresTtyError(); + return error.RemoveSelectionRequiresTty; + } + return err; + }; + return; + } + + const usage_api_enabled = if (interactive_remove) apiModeUsesApi(reg.api.usage, opts.api_mode) else false; + const account_api_enabled = if (interactive_remove) apiModeUsesApi(reg.api.account, opts.api_mode) else false; var usage_state: ?ForegroundUsageRefreshState = null; defer if (usage_state) |*state| state.deinit(allocator); if (interactive_remove) { - if (usage_api_enabled) { - usage_state = refreshForegroundUsageForDisplayWithBatchFetcherAndDebugUsingApiEnabledWithBatchFailurePolicy( + if (opts.api_mode == .force_api) { + try ensureForegroundNodeAvailableWithApiEnabled( allocator, codex_home, ®, - null, + .remove_account, usage_api_enabled, - true, - ) catch |err| switch (err) { - error.OutOfMemory => return err, - else => null, - }; + account_api_enabled, + ); } + + usage_state = refreshForegroundUsageForDisplayWithBatchFetcherAndDebugUsingApiEnabledWithBatchFailurePolicy( + allocator, + codex_home, + ®, + null, + usage_api_enabled, + opts.api_mode == .force_api, + ) catch |err| switch (err) { + error.OutOfMemory => return err, + else => if (opts.api_mode == .force_api) return err else null, + }; + maybeRefreshForegroundAccountNamesWithAccountApiEnabled( allocator, codex_home, @@ -2427,7 +2801,9 @@ fn handleRemove(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. account_api_enabled, ) catch |err| switch (err) { error.OutOfMemory => return err, - else => {}, + else => { + if (opts.api_mode == .force_api) return err; + }, }; } @@ -2452,7 +2828,7 @@ fn handleRemove(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. continue; } - var matches = try findMatchingAccounts(allocator, ®, selector); + var matches = try findMatchingAccountsForRemove(allocator, ®, selector); defer matches.deinit(allocator); if (matches.items.len == 0) { @@ -2515,47 +2891,7 @@ fn handleRemove(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. removed_labels.deinit(allocator); } - const current_active_account_key = if (trackedActiveAccountKey(®)) |key| - try allocator.dupe(u8, key) - else - null; - defer if (current_active_account_key) |key| allocator.free(key); - - var current_auth_state = try loadCurrentAuthState(allocator, codex_home); - defer current_auth_state.deinit(allocator); - - const active_removed = if (current_active_account_key) |key| - selectionContainsAccountKey(®, selected.?, key) - else - false; - const allow_auth_file_update = if (current_active_account_key) |key| - active_removed and ((current_auth_state.syncable and current_auth_state.record_key != null and - std.mem.eql(u8, current_auth_state.record_key.?, key)) or current_auth_state.missing) - else if (current_auth_state.missing) - true - else if (opts.all) - current_auth_state.syncable and current_auth_state.record_key != null and - selectionContainsAccountKey(®, selected.?, current_auth_state.record_key.?) - else - false; - - const replacement_account_key = if (active_removed) - try selectBestRemainingAccountKeyByUsageAlloc(allocator, ®, selected.?) - else - null; - defer if (replacement_account_key) |key| allocator.free(key); - - if (replacement_account_key) |key| { - if (allow_auth_file_update) { - try registry.replaceActiveAuthWithAccountByKey(allocator, codex_home, ®, key); - } else { - try registry.setActiveAccountKey(allocator, ®, key); - } - } - - try registry.removeAccounts(allocator, codex_home, ®, selected.?); - try reconcileActiveAuthAfterRemove(allocator, codex_home, ®, allow_auth_file_update); - try registry.saveRegistry(allocator, codex_home, ®); + try removeSelectedAccountsAndPersist(allocator, codex_home, ®, selected.?, opts.all); try cli.printRemoveSummary(removed_labels.items); } diff --git a/src/tests/cli_bdd_test.zig b/src/tests/cli_bdd_test.zig index a7a3249..2302ddd 100644 --- a/src/tests/cli_bdd_test.zig +++ b/src/tests/cli_bdd_test.zig @@ -196,6 +196,21 @@ test "Scenario: Given list with skip-api flag when parsing then local-only displ } } +test "Scenario: Given list with live flag when parsing then live mode is preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "list", "--live" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .list => |opts| try std.testing.expect(opts.live), + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + test "Scenario: Given list with api flag when parsing then forced api mode is preserved" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "list", "--api" }; @@ -285,8 +300,8 @@ test "Scenario: Given help when rendering then login and command help notes are try std.testing.expect(std.mem.indexOf(u8, help, "`config api enable` may trigger OpenAI account restrictions or suspension in some environments.") != null); try std.testing.expect(std.mem.indexOf(u8, help, "login") != null); try std.testing.expect(std.mem.indexOf(u8, help, "clean") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "switch [--api|--skip-api] | switch ") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "remove [...] | remove --all") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "switch [--live] [--api|--skip-api] | switch ") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "remove [--live] [...] | remove --all") != null); try std.testing.expect(std.mem.indexOf(u8, help, "Delete backup and stale files under accounts/") != null); try std.testing.expect(std.mem.indexOf(u8, help, "status") != null); try std.testing.expect(std.mem.indexOf(u8, help, "config") != null); @@ -309,7 +324,7 @@ test "Scenario: Given simple command help when rendering then examples are omitt const help = aw.written(); try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth list") != null); try std.testing.expect(std.mem.indexOf(u8, help, "List available accounts.") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "Usage:\n codex-auth list [--debug] [--api|--skip-api]\n") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "Usage:\n codex-auth list [--debug] [--live] [--api|--skip-api]\n") != null); try std.testing.expect(std.mem.indexOf(u8, help, "Examples:") == null); } @@ -721,6 +736,33 @@ test "Scenario: Given switch query with skip-api flag when parsing then usage er try expectUsageError(result, .switch_account, "does not support"); } +test "Scenario: Given switch interactive with live flag when parsing then live mode is preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "switch", "--live" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .switch_account => |opts| { + try std.testing.expect(opts.live); + try std.testing.expect(opts.query == null); + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given switch query with live flag when parsing then usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "switch", "--live", "02" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + try expectUsageError(result, .switch_account, "does not support"); +} + test "Scenario: Given switch query with api flag when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "switch", "--api", "02" }; @@ -828,6 +870,25 @@ test "Scenario: Given interactive remove with skip-api flag when parsing then sk } } +test "Scenario: Given interactive remove with live flag when parsing then live mode is preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "remove", "--live" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .remove_account => |opts| { + try std.testing.expect(opts.live); + try std.testing.expectEqual(@as(usize, 0), opts.selectors.len); + try std.testing.expect(!opts.all); + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + test "Scenario: Given interactive remove with api flag when parsing then api mode is preserved" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "remove", "--api" }; @@ -856,6 +917,15 @@ test "Scenario: Given remove query with skip-api flag when parsing then usage er try expectUsageError(result, .remove_account, "do not support"); } +test "Scenario: Given remove query with live flag when parsing then usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "remove", "--live", "01" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + try expectUsageError(result, .remove_account, "do not support"); +} + test "Scenario: Given remove query with api flag when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "remove", "--api", "work" }; diff --git a/src/tests/e2e_cli_test.zig b/src/tests/e2e_cli_test.zig index 2891b01..8325a13 100644 --- a/src/tests/e2e_cli_test.zig +++ b/src/tests/e2e_cli_test.zig @@ -1522,7 +1522,7 @@ test "Scenario: Given switch query with api flag when running switch then it ret try expectFailure(result); try std.testing.expectEqualStrings("", result.stdout); - try std.testing.expect(std.mem.indexOf(u8, result.stderr, "does not support `--api` or `--skip-api`") != null); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, "does not support `--live`, `--api`, or `--skip-api`") != null); } test "Scenario: Given switch query with skip-api flag when running switch then it returns a usage error" { @@ -1558,7 +1558,7 @@ test "Scenario: Given switch query with skip-api flag when running switch then i try expectFailure(result); try std.testing.expectEqualStrings("", result.stdout); - try std.testing.expect(std.mem.indexOf(u8, result.stderr, "does not support `--api` or `--skip-api`") != null); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, "does not support `--live`, `--api`, or `--skip-api`") != null); } test "Scenario: Given switch with skip-api when running interactively then it does not require api refresh executables" { @@ -1694,6 +1694,48 @@ test "Scenario: Given remove query with one match when running remove then it de keeper_backup.close(); } +test "Scenario: Given remove with account key selector when running remove then it deletes the matching account" { + 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); + + try seedRegistryWithAccounts(gpa, home_root, "keeper@example.com", &[_]SeedAccount{ + .{ .email = "robot09@example.com", .alias = "" }, + .{ .email = "keeper@example.com", .alias = "" }, + }); + + const codex_home = try codexHomeAlloc(gpa, home_root); + defer gpa.free(codex_home); + const removed_account_key = try bdd.accountKeyForEmailAlloc(gpa, "robot09@example.com"); + defer gpa.free(removed_account_key); + + const result = try runCliWithIsolatedHomeAndStdin( + gpa, + project_root, + home_root, + &[_][]const u8{ "remove", removed_account_key }, + "", + ); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectSuccess(result); + try std.testing.expect(std.mem.indexOf(u8, result.stdout, "robot09@example.com") != null); + try std.testing.expectEqualStrings("", result.stderr); + + var loaded = try registry.loadRegistry(gpa, codex_home); + defer loaded.deinit(gpa); + try std.testing.expectEqual(@as(usize, 1), loaded.accounts.items.len); + try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[0].email, "keeper@example.com")); +} + test "Scenario: Given remove with multiple selectors when running remove then it deletes all selected accounts" { const gpa = std.testing.allocator; const project_root = try projectRootAlloc(gpa); @@ -1771,7 +1813,7 @@ test "Scenario: Given remove query with api flag when running remove then it ret try expectFailure(result); try std.testing.expectEqualStrings("", result.stdout); - try std.testing.expect(std.mem.indexOf(u8, result.stderr, "do not support `--api` or `--skip-api`") != null); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, "do not support `--live`, `--api`, or `--skip-api`") != null); } test "Scenario: Given remove query with skip-api flag when running remove then it returns a usage error" { @@ -1807,10 +1849,10 @@ test "Scenario: Given remove query with skip-api flag when running remove then i try expectFailure(result); try std.testing.expectEqualStrings("", result.stdout); - try std.testing.expect(std.mem.indexOf(u8, result.stderr, "do not support `--api` or `--skip-api`") != null); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, "do not support `--live`, `--api`, or `--skip-api`") != null); } -test "Scenario: Given interactive remove with api flag and missing refresh executables when running remove then it falls back to stored data" { +test "Scenario: Given interactive remove with api flag and missing refresh executables when running remove then it requires api refresh executables" { const gpa = std.testing.allocator; const project_root = try projectRootAlloc(gpa); defer gpa.free(project_root); @@ -1845,14 +1887,12 @@ test "Scenario: Given interactive remove with api flag and missing refresh execu defer gpa.free(result.stdout); defer gpa.free(result.stderr); - try expectSuccess(result); - try std.testing.expect(std.mem.indexOf(u8, result.stdout, "Select accounts to delete:") != null); - try std.testing.expectEqualStrings("", result.stderr); + try expectFailure(result); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, "Node.js 22+") != null); var loaded = try registry.loadRegistry(gpa, codex_home); defer loaded.deinit(gpa); - try std.testing.expectEqual(@as(usize, 1), loaded.accounts.items.len); - try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[0].email, "alpha@example.com")); + try std.testing.expectEqual(@as(usize, 2), loaded.accounts.items.len); } test "Scenario: Given remove without selectors when running remove then it does not require api refresh executables" { From 4e4e30532048d9ed4a1b3bec66e60978f94e14eb Mon Sep 17 00:00:00 2001 From: Loongphy Date: Mon, 20 Apr 2026 17:14:28 +0800 Subject: [PATCH 08/11] feat: add switch live auto mode --- README.md | 6 +- docs/api-refresh.md | 8 +- docs/auto-switch.md | 2 + docs/implement.md | 19 ++- src/cli.zig | 236 ++++++++++++++++++++++++++++++++++++- src/main.zig | 176 +++++++++++++++++++++++++-- src/tests/cli_bdd_test.zig | 39 +++++- src/tests/e2e_cli_test.zig | 4 +- 8 files changed, 465 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 373e8c9..65d2d08 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ Remove-Item "$env:LOCALAPPDATA\codex-auth\bin\codex-auth-auto.exe" -Force -Error |---------|-------------| | `codex-auth list [--debug] [--live] [--api|--skip-api]` | List all accounts. `--live` keeps refreshing the terminal view; `--api` forces remote refresh, while `--skip-api` forbids remote API use for this command. | | `codex-auth login [--device-auth]` | Run `codex login` (optionally with `--device-auth`), then add the current account | -| `codex-auth switch [--live] [--api|--skip-api]` | Switch the active account interactively. Without `--live` it exits after one switch; with `--live` it stays open and keeps refreshing. | +| `codex-auth switch [--live] [--auto] [--api|--skip-api]` | Switch the active account interactively. Without `--live` it exits after one switch; with `--live` it stays open and keeps refreshing. `--auto` requires `--live` and auto-switches away from the current account when the live view shows it as exhausted or returns a non-200 usage API status. | | `codex-auth switch ` | Switch the active account directly by row number, alias, or fuzzy match using stored local data only. | | `codex-auth remove [--live] [--api|--skip-api]` | Interactive remove. `--live` keeps the picker open after each deletion; `--api` forces remote refresh and `--skip-api` forbids remote API use for this command. | | `codex-auth remove [...]` | Remove one or more accounts by row number, alias, email, account name, or `account_key` match using stored local data. | @@ -144,11 +144,13 @@ codex-auth list --skip-api # forbid usage/team-name API refresh for this comma Interactive `switch` shows email, 5h, weekly, and last activity. Without ``, it follows the configured refresh mode before opening the picker. `switch` is single-shot by default; `switch --live` keeps the picker open after Enter and updates the footer with the latest switch result. +`switch --live --auto` keeps watching the current live display and auto-switches only when the active account reaches `0%` on 5h or weekly, or when the usage API returns a non-200 status for the active account. Auto-switch candidates still follow the live picker rules and also skip candidates whose current 5h or weekly value is already `0%`. Use `--api` to force a foreground remote refresh first, or `--skip-api` to forbid remote API use and rely on local-only usage refresh where available. ```shell codex-auth switch codex-auth switch --live +codex-auth switch --live --auto codex-auth switch --api codex-auth switch --skip-api ``` @@ -157,7 +159,7 @@ codex-auth switch --skip-api `` can be a displayed row number, an alias, or a fuzzy email/alias match. The row number follows the interactive `switch` list, and the same number from `codex-auth list` also works because both commands use the same ordering. -`switch ` always resolves from stored local data and does not accept `--live`, `--api`, or `--skip-api`. +`switch ` always resolves from stored local data and does not accept `--live`, `--auto`, `--api`, or `--skip-api`. ```shell codex-auth switch 02 # switch by displayed row number diff --git a/docs/api-refresh.md b/docs/api-refresh.md index 1a4e813..bf37753 100644 --- a/docs/api-refresh.md +++ b/docs/api-refresh.md @@ -46,7 +46,11 @@ The `accounts/check` response is parsed by `chatgpt_account_id`. `name: null` an - when a stored account snapshot cannot make a ChatGPT usage request because it is missing the required ChatGPT auth fields, the corresponding `list` / `switch` row shows `MissingAuth` in both usage columns until a later successful refresh replaces it - when `api.usage = false`, foreground refresh still uses only the active local rollout data because local session files do not identify the other stored accounts - `list --api` forces foreground usage refresh for this command even when `api.usage = false`; `list --skip-api` skips foreground usage refresh completely and renders only the stored registry data -- interactive `switch` follows the configured foreground usage mode by default; `switch ` always resolves selectors locally from stored data and does not accept `--api` or `--skip-api` +- interactive `switch` follows the configured foreground usage mode by default; `switch --live --auto` uses the same live display data source and only adds foreground auto-selection on top of that refreshed view +- in `switch --live --auto`, the active account triggers a foreground auto-switch only when the live display shows `0%` on the 5h window, `0%` on the weekly window, or a numeric non-`200` usage API status overlay for the active row +- `switch --live --auto` still excludes errored rows from candidate selection, and it also skips candidates whose current displayed 5h or weekly value is already `0%` +- with `--skip-api` or `api.usage = false`, only the active account can be refreshed from local rollout data; non-active foreground auto-switch candidates still come from stored registry data +- `switch ` always resolves selectors locally from stored data and does not accept `--auto`, `--api`, or `--skip-api` - interactive `remove` stays local-only by default; `remove --api` does a best-effort foreground usage refresh attempt for picker display only. Successful rows show live usage data; rows that cannot refresh may show HTTP/error overlays in the picker instead. Refresh problems do not block deletion, and setup or batch-level failures still fall back to the stored registry list - `remove ` and `remove --all` always resolve selectors from stored local data and do not accept `--api` or `--skip-api` - `switch` does not refresh usage again after the new account is activated @@ -61,7 +65,7 @@ The `accounts/check` response is parsed by `chatgpt_account_id`. `name: null` an - `login` refreshes immediately after the new active auth is ready. - Single-file `import` refreshes immediately for the imported auth context. - `list --api` forces synchronous `accounts/check` refresh for this command even when `api.account = false`; `list --skip-api` skips it and uses stored metadata only. -- interactive `switch` follows the configured account-name refresh mode by default; `switch ` always stays local-only and does not accept `--api` or `--skip-api`. +- interactive `switch` follows the configured account-name refresh mode by default, including `switch --live --auto`; `switch ` always stays local-only and does not accept `--auto`, `--api`, or `--skip-api`. - interactive `remove` stays local-only by default; `remove --api` does a best-effort synchronous `accounts/check` refresh for picker display only, and account-name refresh failures leave the stored metadata in place without blocking deletion. - `remove ` and `remove --all` always stay local-only and do not accept `--api` or `--skip-api`. - `list` and interactive `switch` load the request auth context from the current active `auth.json` when they do refresh. diff --git a/docs/auto-switch.md b/docs/auto-switch.md index d3ca510..8ab145e 100644 --- a/docs/auto-switch.md +++ b/docs/auto-switch.md @@ -2,6 +2,8 @@ This document is the single source of truth for `codex-auth` background auto-switch behavior. +It does not describe the foreground `codex-auth switch --live --auto` picker mode. That live picker mode uses its own immediate display-driven trigger rules and does not read `auto_switch.threshold_5h_percent` or `auto_switch.threshold_weekly_percent`. + ## Commands and Stored Config User-facing commands: diff --git a/docs/implement.md b/docs/implement.md index 7f97184..255909c 100644 --- a/docs/implement.md +++ b/docs/implement.md @@ -152,9 +152,10 @@ Important limits: ## Switching Accounts -`switch` supports two modes: +`switch` supports three foreground forms: - Interactive: `codex-auth switch` +- Interactive live: `codex-auth switch --live [--auto] [--api|--skip-api]` - Non-interactive: `codex-auth switch ` For non-interactive switching, the target account is matched case-insensitively by: @@ -171,11 +172,23 @@ When switching: 2. The selected account’s `accounts/.auth.json` is copied to `~/.codex/auth.json`. 3. The registry’s `active_account_key` is updated to that account’s `record_key`. -When `api.usage = true`, interactive `codex-auth switch` refreshes usage for all stored accounts before rendering account choices, using a maximum concurrency of `3`. When a per-account foreground usage request returns a non-`200` HTTP status, the picker shows that status in both usage columns for that row. When a stored account snapshot cannot make a ChatGPT usage request because the required ChatGPT auth fields are missing, the picker shows `MissingAuth` in both usage columns for that row. No extra usage refresh is attempted after the switch completes. +When `api.usage = true`, interactive `codex-auth switch` refreshes usage for all stored accounts before rendering account choices, using a maximum concurrency of `3`. When a per-account foreground usage request returns a non-`200` HTTP status, the picker shows that status in both usage columns for that row. When a stored account snapshot cannot make a ChatGPT usage request because the required ChatGPT auth fields are missing, the picker shows `MissingAuth` in both usage columns for that row. No extra usage refresh is attempted after the single-shot `switch` command completes. When `api.usage = false`, interactive `codex-auth switch` keeps the existing local-only behavior and can refresh only the active account from the newest local rollout data. -`codex-auth switch ` now stays local-only: it resolves matches from the stored registry, switches immediately on a single match, and does not wait for foreground usage or account-name API refresh before switching. +`codex-auth switch --live` keeps the picker open after each successful switch and keeps refreshing the display in-place. `--api` and `--skip-api` still override the configured usage/account API settings for that command only. + +`codex-auth switch --live --auto` adds a foreground auto-switch loop on top of the live picker. It does not use the background watcher thresholds from `config auto`. Instead, it switches only when the currently active row in the live display: + +- shows `0%` remaining on the 5h window, or +- shows `0%` remaining on the weekly window, or +- shows a numeric non-`200` usage API status overlay + +Foreground live auto-switch candidates still use the same selectable rows as the live picker, so rows with usage/API error overlays are excluded. In addition, rows whose current displayed 5h or weekly value is already `0%` are also skipped, to avoid leaving one exhausted account for another exhausted account. + +When `--skip-api` or local-only usage mode is in effect, only the active account can be refreshed from local rollout data. Non-active candidates still come from the stored registry snapshot, so `switch --live --auto` is only as current as that local snapshot for non-active accounts. + +`codex-auth switch ` now stays local-only: it resolves matches from the stored registry, switches immediately on a single match, and does not wait for foreground usage or account-name API refresh before switching. This query form does not accept `--live`, `--auto`, `--api`, or `--skip-api`. Grouped account-name metadata refresh, when needed, now runs in the same foreground pre-selection phase as the interactive picker path; see [docs/api-refresh.md](./api-refresh.md). diff --git a/src/cli.zig b/src/cli.zig index b70db04..023d712 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -617,6 +617,7 @@ pub const ImportOptions = struct { pub const SwitchOptions = struct { query: ?[]u8, live: bool = false, + auto: bool = false, api_mode: ApiMode = .default, }; pub const RemoveOptions = struct { @@ -866,6 +867,14 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Pars opts.live = true; continue; } + if (std.mem.eql(u8, arg, "--auto")) { + if (opts.auto) { + if (opts.query) |query| allocator.free(query); + return usageErrorResult(allocator, .switch_account, "duplicate `--auto` for `switch`.", .{}); + } + opts.auto = true; + continue; + } if (std.mem.eql(u8, arg, "--api")) { switch (opts.api_mode) { .default => opts.api_mode = .force_api, @@ -904,12 +913,16 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Pars } opts.query = try allocator.dupe(u8, arg); } - if (opts.query != null and (opts.api_mode != .default or opts.live)) { + if (opts.auto and !opts.live) { + if (opts.query) |query| allocator.free(query); + return usageErrorResult(allocator, .switch_account, "`--auto` requires `--live` for `switch`.", .{}); + } + if (opts.query != null and (opts.api_mode != .default or opts.live or opts.auto)) { if (opts.query) |query| allocator.free(query); return usageErrorResult( allocator, .switch_account, - "`switch ` does not support `--live`, `--api`, or `--skip-api`.", + "`switch ` does not support `--live`, `--auto`, `--api`, or `--skip-api`.", .{}, ); } @@ -1237,7 +1250,7 @@ pub fn writeHelp( .{ .name = "status", .description = "Show auto-switch and usage API status" }, .{ .name = "login", .description = "Login and add the current account" }, .{ .name = "import", .description = "Import auth files or rebuild registry" }, - .{ .name = "switch [--live] [--api|--skip-api] | switch ", .description = "Switch the active account" }, + .{ .name = "switch [--live] [--auto] [--api|--skip-api] | switch ", .description = "Switch the active account" }, .{ .name = "remove [--live] [...] | remove --all", .description = "Remove one or more accounts" }, .{ .name = "clean", .description = "Delete backup and stale files under accounts/" }, .{ .name = "config", .description = "Manage configuration" }, @@ -1419,7 +1432,7 @@ fn writeUsageSection(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth import --purge []\n"); }, .switch_account => { - try out.writeAll(" codex-auth switch [--live] [--api|--skip-api]\n"); + try out.writeAll(" codex-auth switch [--live] [--auto] [--api|--skip-api]\n"); try out.writeAll(" codex-auth switch \n"); }, .remove_account => { @@ -1471,6 +1484,7 @@ fn writeExamplesSection(out: *std.Io.Writer, topic: HelpTopic) !void { .switch_account => { try out.writeAll(" codex-auth switch\n"); try out.writeAll(" codex-auth switch --live\n"); + try out.writeAll(" codex-auth switch --live --auto\n"); try out.writeAll(" codex-auth switch --api\n"); try out.writeAll(" codex-auth switch --skip-api\n"); try out.writeAll(" codex-auth switch work\n"); @@ -1922,6 +1936,7 @@ pub const SwitchLiveActionController = struct { allocator: std.mem.Allocator, account_key: []const u8, ) anyerror!LiveActionOutcome, + auto_switch: bool = false, }; pub const RemoveLiveActionController = struct { @@ -2290,11 +2305,13 @@ pub fn runSwitchLiveActions( var number_buf: [8]u8 = undefined; var number_len: usize = 0; + var auto_check_pending = controller.auto_switch; while (true) { if (try controller.refresh.maybe_take_updated_display(controller.refresh.context)) |updated| { current_display.deinit(allocator); current_display = updated; + auto_check_pending = controller.auto_switch; } const borrowed = current_display.borrowed(); @@ -2312,6 +2329,31 @@ pub fn runSwitchLiveActions( try replaceSelectedAccountKeyForSelectable(allocator, &selected_account_key, &rows, borrowed.reg, selected_idx.?); } + if (auto_check_pending) { + if (try maybeAutoSwitchTargetKeyAlloc(allocator, borrowed, &rows)) |target_key| { + defer allocator.free(target_key); + const outcome = controller.apply_selection(controller.refresh.context, allocator, target_key) catch |err| { + replaceOptionalOwnedString( + allocator, + &action_message, + try std.fmt.allocPrint(allocator, "Auto-switch failed: {s}", .{@errorName(err)}), + ); + replaceOptionalOwnedString(allocator, &selected_account_key, try allocator.dupe(u8, target_key)); + number_len = 0; + auto_check_pending = false; + continue; + }; + current_display.deinit(allocator); + current_display = outcome.updated_display; + replaceOptionalOwnedString(allocator, &action_message, outcome.action_message); + replaceOptionalOwnedString(allocator, &selected_account_key, try allocator.dupe(u8, target_key)); + number_len = 0; + auto_check_pending = controller.auto_switch; + continue; + } + auto_check_pending = false; + } + const status_line = try controller.refresh.build_status_line(controller.refresh.context, allocator, borrowed); defer allocator.free(status_line); const selected_display_idx = selectedDisplayIndexForRender(&rows, selected_idx, number_buf[0..number_len]); @@ -2381,6 +2423,7 @@ pub fn runSwitchLiveActions( replaceOptionalOwnedString(allocator, &action_message, outcome.action_message); replaceOptionalOwnedString(allocator, &selected_account_key, try allocator.dupe(u8, target_key)); number_len = 0; + auto_check_pending = controller.auto_switch; }, .quit => return, .backspace => { @@ -2488,6 +2531,7 @@ pub fn runSwitchLiveActions( replaceOptionalOwnedString(allocator, &action_message, outcome.action_message); replaceOptionalOwnedString(allocator, &selected_account_key, try allocator.dupe(u8, target_key)); number_len = 0; + auto_check_pending = controller.auto_switch; continue; } if (isQuitKey(b[i])) return; @@ -2985,6 +3029,90 @@ fn selectedDisplayIndexForRender( return null; } +fn numericUsageOverrideStatus(usage_override: ?[]const u8) ?u16 { + const value = usage_override orelse return null; + return std.fmt.parseInt(u16, value, 10) catch null; +} + +fn accountHasExhaustedUsage(rec: *const registry.AccountRecord, now: i64) bool { + const rate_5h = resolveRateWindow(rec.last_usage, 300, true); + const rate_week = resolveRateWindow(rec.last_usage, 10080, false); + const rem_5h = registry.remainingPercentAt(rate_5h, now); + const rem_week = registry.remainingPercentAt(rate_week, now); + return (rem_5h != null and rem_5h.? == 0) or (rem_week != null and rem_week.? == 0); +} + +fn shouldAutoSwitchActiveAccount(display: SwitchSelectionDisplay, now: i64) bool { + const active_account_key = display.reg.active_account_key orelse return false; + const active_idx = registry.findAccountIndexByAccountKey(display.reg, active_account_key) orelse return false; + + if (numericUsageOverrideStatus(usageOverrideForAccount(display.usage_overrides, active_idx))) |status_code| { + return status_code != 200; + } + + return accountHasExhaustedUsage(&display.reg.accounts.items[active_idx], now); +} + +fn autoSwitchCandidateIsBetter( + candidate_score: ?i64, + candidate_last_usage_at: ?i64, + best_score: ?i64, + best_last_usage_at: i64, +) bool { + if (candidate_score != null and best_score == null) return true; + if (candidate_score == null and best_score != null) return false; + if (candidate_score != null and best_score != null and candidate_score.? != best_score.?) { + return candidate_score.? > best_score.?; + } + + return (candidate_last_usage_at orelse -1) > best_last_usage_at; +} + +fn bestAutoSwitchCandidateSelectableIndex( + rows: *const SwitchRows, + reg: *registry.Registry, + now: i64, +) ?usize { + const active_account_key = reg.active_account_key orelse return null; + + var best_selectable_idx: ?usize = null; + var best_score: ?i64 = null; + var best_last_usage_at: i64 = -1; + + for (rows.selectable_row_indices, 0..) |row_idx, selectable_idx| { + const account_idx = rows.items[row_idx].account_index orelse continue; + const rec = ®.accounts.items[account_idx]; + if (std.mem.eql(u8, rec.account_key, active_account_key)) continue; + if (accountHasExhaustedUsage(rec, now)) continue; + + const candidate_score = registry.usageScoreAt(rec.last_usage, now); + if (best_selectable_idx == null or autoSwitchCandidateIsBetter( + candidate_score, + rec.last_usage_at, + best_score, + best_last_usage_at, + )) { + best_selectable_idx = selectable_idx; + best_score = candidate_score; + best_last_usage_at = rec.last_usage_at orelse -1; + } + } + + return best_selectable_idx; +} + +fn maybeAutoSwitchTargetKeyAlloc( + allocator: std.mem.Allocator, + display: SwitchSelectionDisplay, + rows: *const SwitchRows, +) !?[]u8 { + const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); + if (!shouldAutoSwitchActiveAccount(display, now)) return null; + + const selectable_idx = bestAutoSwitchCandidateSelectableIndex(rows, display.reg, now) orelse return null; + return try accountKeyForSelectableAlloc(allocator, rows, display.reg, selectable_idx); +} + fn dupSelectedAccountKey( allocator: std.mem.Allocator, rows: *const SwitchRows, @@ -4484,6 +4612,23 @@ fn appendTestAccount( }); } +fn testUsageSnapshot(now: i64, used_5h: f64, used_weekly: f64) registry.RateLimitSnapshot { + return .{ + .primary = .{ + .used_percent = used_5h, + .window_minutes = 300, + .resets_at = now + 3600, + }, + .secondary = .{ + .used_percent = used_weekly, + .window_minutes = 10080, + .resets_at = now + 7 * 24 * 3600, + }, + .credits = null, + .plan_type = .pro, + }; +} + test "Scenario: Given grouped accounts when rendering switch list then child rows keep indentation" { const gpa = std.testing.allocator; var reg = makeTestRegistry(); @@ -4544,6 +4689,89 @@ test "Scenario: Given usage overrides when selecting switch accounts then errore try std.testing.expect(std.mem.eql(u8, accountIdForSelectable(&rows, ®, 0), "user-1::acc-1")); } +test "Scenario: Given exhausted active usage when picking an auto-switch target then the best healthy candidate is chosen" { + const gpa = std.testing.allocator; + var reg = makeTestRegistry(); + defer reg.deinit(gpa); + + const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); + + try appendTestAccount(gpa, ®, "user-1::acc-1", "active@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-2", "backup-a@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-3", "backup-b@example.com", "", .team); + reg.active_account_key = try gpa.dupe(u8, "user-1::acc-1"); + reg.accounts.items[0].last_usage = testUsageSnapshot(now, 100, 10); + reg.accounts.items[1].last_usage = testUsageSnapshot(now, 35, 15); + reg.accounts.items[2].last_usage = testUsageSnapshot(now, 5, 8); + + var rows = try buildSwitchRowsWithUsageOverrides(gpa, ®, null); + defer rows.deinit(gpa); + try filterErroredRowsFromSelectableIndices(gpa, &rows); + + const target_key = try maybeAutoSwitchTargetKeyAlloc(gpa, .{ + .reg = ®, + .usage_overrides = null, + }, &rows); + defer if (target_key) |value| gpa.free(value); + + try std.testing.expect(target_key != null); + try std.testing.expectEqualStrings("user-1::acc-3", target_key.?); +} + +test "Scenario: Given an active api status error when picking an auto-switch target then a healthy candidate is chosen" { + const gpa = std.testing.allocator; + var reg = makeTestRegistry(); + defer reg.deinit(gpa); + + const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); + + try appendTestAccount(gpa, ®, "user-1::acc-1", "active@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-2", "backup@example.com", "", .team); + reg.active_account_key = try gpa.dupe(u8, "user-1::acc-1"); + reg.accounts.items[0].last_usage = testUsageSnapshot(now, 20, 20); + reg.accounts.items[1].last_usage = testUsageSnapshot(now, 10, 10); + + const usage_overrides = [_]?[]const u8{ "403", null }; + var rows = try buildSwitchRowsWithUsageOverrides(gpa, ®, &usage_overrides); + defer rows.deinit(gpa); + try filterErroredRowsFromSelectableIndices(gpa, &rows); + + const target_key = try maybeAutoSwitchTargetKeyAlloc(gpa, .{ + .reg = ®, + .usage_overrides = &usage_overrides, + }, &rows); + defer if (target_key) |value| gpa.free(value); + + try std.testing.expect(target_key != null); + try std.testing.expectEqualStrings("user-1::acc-2", target_key.?); +} + +test "Scenario: Given only exhausted candidates when picking an auto-switch target then no target is returned" { + const gpa = std.testing.allocator; + var reg = makeTestRegistry(); + defer reg.deinit(gpa); + + const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); + + try appendTestAccount(gpa, ®, "user-1::acc-1", "active@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-1::acc-2", "backup@example.com", "", .team); + reg.active_account_key = try gpa.dupe(u8, "user-1::acc-1"); + reg.accounts.items[0].last_usage = testUsageSnapshot(now, 100, 10); + reg.accounts.items[1].last_usage = testUsageSnapshot(now, 100, 100); + + var rows = try buildSwitchRowsWithUsageOverrides(gpa, ®, null); + defer rows.deinit(gpa); + try filterErroredRowsFromSelectableIndices(gpa, &rows); + + const target_key = try maybeAutoSwitchTargetKeyAlloc(gpa, .{ + .reg = ®, + .usage_overrides = null, + }, &rows); + defer if (target_key) |value| gpa.free(value); + + try std.testing.expect(target_key == null); +} + test "Scenario: Given usage overrides when rendering switch list then errored rows still show full display numbers" { const gpa = std.testing.allocator; var reg = makeTestRegistry(); diff --git a/src/main.zig b/src/main.zig index 2c6dc71..7de2c8e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -18,6 +18,7 @@ const disable_background_account_name_refresh_env = "CODEX_AUTH_DISABLE_BACKGROU const foreground_usage_refresh_concurrency: usize = 5; const switch_live_api_refresh_interval_ms: i64 = 30_000; const switch_live_local_refresh_interval_ms: i64 = 10_000; +const switch_live_debug_max_lines: usize = 8; fn getEnvMap(allocator: std.mem.Allocator) !std.process.Environ.Map { return try app_runtime.currentEnviron().createMap(allocator); @@ -129,8 +130,15 @@ const DebugUsageLabelState = struct { } }; +const ForegroundUsageDebugMirror = struct { + context: *anyopaque, + append_line: *const fn (context: *anyopaque, line: []const u8) anyerror!void, +}; + pub const ForegroundUsageDebugLogger = struct { - writer: *std.Io.Writer, + allocator: std.mem.Allocator = std.heap.smp_allocator, + writer: ?*std.Io.Writer, + mirror: ?ForegroundUsageDebugMirror = null, mutex: std.Io.Mutex = .init, pub fn init(writer: *std.Io.Writer) ForegroundUsageDebugLogger { @@ -139,15 +147,98 @@ pub const ForegroundUsageDebugLogger = struct { }; } + pub fn initMirrored( + allocator: std.mem.Allocator, + writer: ?*std.Io.Writer, + mirror: ForegroundUsageDebugMirror, + ) ForegroundUsageDebugLogger { + return .{ + .allocator = allocator, + .writer = writer, + .mirror = mirror, + }; + } + pub fn print(self: *ForegroundUsageDebugLogger, comptime fmt: []const u8, args: anytype) !void { self.mutex.lockUncancelable(app_runtime.io()); defer self.mutex.unlock(app_runtime.io()); - try self.writer.print(fmt, args); - try self.writer.flush(); + if (self.writer) |writer| { + try writer.print(fmt, args); + try writer.flush(); + } + if (self.mirror) |mirror| { + const line = try std.fmt.allocPrint(self.allocator, fmt, args); + defer self.allocator.free(line); + try mirror.append_line(mirror.context, std.mem.trim(u8, line, "\r\n")); + } } }; +const SwitchLiveDebugLog = struct { + allocator: std.mem.Allocator, + lines: std.ArrayList([]u8) = .empty, + mutex: std.Io.Mutex = .init, + + fn init(allocator: std.mem.Allocator) @This() { + return .{ + .allocator = allocator, + }; + } + + fn deinit(self: *@This()) void { + self.mutex.lockUncancelable(app_runtime.io()); + defer self.mutex.unlock(app_runtime.io()); + + for (self.lines.items) |line| self.allocator.free(line); + self.lines.deinit(self.allocator); + self.* = undefined; + } + + fn mirror(self: *@This()) ForegroundUsageDebugMirror { + return .{ + .context = @ptrCast(self), + .append_line = switchLiveDebugLogAppendLine, + }; + } + + fn appendLine(self: *@This(), line: []const u8) !void { + const trimmed = std.mem.trim(u8, line, "\r\n"); + if (trimmed.len == 0) return; + + self.mutex.lockUncancelable(app_runtime.io()); + defer self.mutex.unlock(app_runtime.io()); + + if (self.lines.items.len == switch_live_debug_max_lines) { + self.allocator.free(self.lines.orderedRemove(0)); + } + const owned = try self.allocator.dupe(u8, trimmed); + errdefer self.allocator.free(owned); + try self.lines.append(self.allocator, owned); + } + + fn buildTextAlloc(self: *@This(), allocator: std.mem.Allocator) !?[]u8 { + self.mutex.lockUncancelable(app_runtime.io()); + defer self.mutex.unlock(app_runtime.io()); + + if (self.lines.items.len == 0) return null; + + var out: std.Io.Writer.Allocating = .init(allocator); + errdefer out.deinit(); + + for (self.lines.items, 0..) |line, idx| { + if (idx != 0) try out.writer.writeAll("\n"); + try out.writer.writeAll(line); + } + return try out.toOwnedSlice(); + } +}; + +fn switchLiveDebugLogAppendLine(context: *anyopaque, line: []const u8) !void { + const debug_log: *SwitchLiveDebugLog = @ptrCast(@alignCast(context)); + try debug_log.appendLine(line); +} + const ForegroundUsageDebugContext = struct { logger: *ForegroundUsageDebugLogger, label_state: *const DebugUsageLabelState, @@ -1548,12 +1639,15 @@ fn handleList(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.Li if (opts.live) { const live_allocator = std.heap.smp_allocator; const strict_refresh = opts.api_mode == .force_api; + var debug_log = SwitchLiveDebugLog.init(live_allocator); + defer debug_log.deinit(); const loaded = try loadSwitchSelectionDisplay( live_allocator, codex_home, opts.api_mode, .list, strict_refresh, + if (opts.debug) &debug_log else null, ); var initial_display: ?cli.OwnedSwitchSelectionDisplay = loaded.display; errdefer if (initial_display) |*display| display.deinit(live_allocator); @@ -1565,6 +1659,8 @@ fn handleList(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.Li opts.api_mode, strict_refresh, loaded.policy, + loaded.refresh_error_name, + if (opts.debug) &debug_log else null, ); defer runtime.deinit(); @@ -1702,6 +1798,7 @@ fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. } std.debug.assert(opts.api_mode == .default); std.debug.assert(!opts.live); + std.debug.assert(!opts.auto); var resolution = try resolveSwitchQueryLocally(allocator, ®, query); defer resolution.deinit(allocator); @@ -1738,8 +1835,10 @@ fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. opts.api_mode, .switch_account, true, + null, ); defer loaded.display.deinit(allocator); + defer if (loaded.refresh_error_name) |name| allocator.free(name); const selected_account_key = cli.selectAccountWithUsageOverrides( allocator, @@ -1766,6 +1865,7 @@ fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. opts.api_mode, .switch_account, strict_refresh, + null, ); var initial_display: ?cli.OwnedSwitchSelectionDisplay = loaded.display; errdefer if (initial_display) |*display| display.deinit(live_allocator); @@ -1777,6 +1877,8 @@ fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. opts.api_mode, strict_refresh, loaded.policy, + loaded.refresh_error_name, + null, ); defer runtime.deinit(); @@ -1788,6 +1890,7 @@ fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. .build_status_line = switchLiveRuntimeBuildStatusLine, }, .apply_selection = switchLiveRuntimeApplySelection, + .auto_switch = opts.auto, }; const transferred_display = initial_display.?; @@ -1811,6 +1914,7 @@ const SwitchLiveRefreshPolicy = struct { const SwitchLoadedDisplay = struct { display: cli.OwnedSwitchSelectionDisplay, policy: SwitchLiveRefreshPolicy, + refresh_error_name: ?[]u8 = null, }; const SwitchLiveRuntime = struct { @@ -1831,6 +1935,7 @@ const SwitchLiveRuntime = struct { last_refresh_error_name: ?[]u8 = null, refresh_interval_ms: i64, mode_label: []const u8, + debug_log: ?*SwitchLiveDebugLog = null, fn init( allocator: std.mem.Allocator, @@ -1839,6 +1944,8 @@ const SwitchLiveRuntime = struct { api_mode: cli.ApiMode, strict_refresh: bool, initial_policy: SwitchLiveRefreshPolicy, + initial_refresh_error_name: ?[]u8, + debug_log: ?*SwitchLiveDebugLog, ) @This() { const io_impl = std.Io.Threaded.init(allocator, .{ .concurrent_limit = .limited(1), @@ -1854,6 +1961,8 @@ const SwitchLiveRuntime = struct { .next_refresh_not_before_ms = now_ms + initial_policy.interval_ms, .refresh_interval_ms = initial_policy.interval_ms, .mode_label = initial_policy.label, + .last_refresh_error_name = initial_refresh_error_name, + .debug_log = debug_log, }; } @@ -1938,6 +2047,14 @@ const SwitchLiveRuntime = struct { self.updated_display = null; } + fn replaceLastRefreshError(self: *@This(), error_name: ?[]u8) void { + const io = self.io_impl.io(); + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + if (self.last_refresh_error_name) |name| self.allocator.free(name); + self.last_refresh_error_name = error_name; + } + fn buildStatusLine(self: *@This(), allocator: std.mem.Allocator, display: cli.SwitchSelectionDisplay) ![]u8 { _ = display; const io = self.io_impl.io(); @@ -1972,11 +2089,23 @@ const SwitchLiveRuntime = struct { try allocator.dupe(u8, ""); defer allocator.free(error_suffix); - return std.fmt.allocPrint( + const base_status_line = try std.fmt.allocPrint( allocator, "Live refresh: {s} | {s}{s}", .{ mode_label, refresh_state, error_suffix }, ); + errdefer allocator.free(base_status_line); + + const debug_text = if (self.debug_log) |debug_log| + try debug_log.buildTextAlloc(allocator) + else + null; + defer if (debug_text) |text| allocator.free(text); + + if (debug_text) |text| { + return std.fmt.allocPrint(allocator, "{s}\n{s}", .{ base_status_line, text }); + } + return base_status_line; } }; @@ -2175,12 +2304,25 @@ fn loadStoredSwitchSelectionDisplay( }; } +fn loadStoredSwitchSelectionDisplayWithRefreshError( + allocator: std.mem.Allocator, + codex_home: []const u8, + api_mode: cli.ApiMode, + refresh_err: anyerror, +) !SwitchLoadedDisplay { + var loaded = try loadStoredSwitchSelectionDisplay(allocator, codex_home, api_mode); + errdefer loaded.display.deinit(allocator); + loaded.refresh_error_name = try allocator.dupe(u8, @errorName(refresh_err)); + return loaded; +} + fn loadSwitchSelectionDisplay( allocator: std.mem.Allocator, codex_home: []const u8, api_mode: cli.ApiMode, target: ForegroundUsageRefreshTarget, strict_refresh: bool, + debug_log: ?*SwitchLiveDebugLog, ) !SwitchLoadedDisplay { var base = try registry.loadRegistry(allocator, codex_home); defer base.deinit(allocator); @@ -2202,10 +2344,15 @@ fn loadSwitchSelectionDisplay( else => { if (strict_refresh) return err; refreshed.deinit(allocator); - return loadStoredSwitchSelectionDisplay(allocator, codex_home, api_mode); + return loadStoredSwitchSelectionDisplayWithRefreshError(allocator, codex_home, api_mode, err); }, }; + var debug_logger: ?ForegroundUsageDebugLogger = null; + if (debug_log) |log| { + debug_logger = ForegroundUsageDebugLogger.initMirrored(allocator, null, log.mirror()); + } + var usage_state = refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitAndDebugUsingApiEnabledAndPersist( allocator, codex_home, @@ -2213,7 +2360,7 @@ fn loadSwitchSelectionDisplay( usage_api.fetchUsageForAuthPathDetailed, usage_api.fetchUsageForAuthPathsDetailedBatch, initForegroundUsagePool, - null, + if (debug_logger) |*logger| logger else null, initial_policy.usage_api_enabled, false, false, @@ -2222,7 +2369,7 @@ fn loadSwitchSelectionDisplay( else => { if (strict_refresh) return err; refreshed.deinit(allocator); - return loadStoredSwitchSelectionDisplay(allocator, codex_home, api_mode); + return loadStoredSwitchSelectionDisplayWithRefreshError(allocator, codex_home, api_mode, err); }, }; errdefer usage_state.deinit(allocator); @@ -2241,7 +2388,7 @@ fn loadSwitchSelectionDisplay( if (strict_refresh) return err; usage_state.deinit(allocator); refreshed.deinit(allocator); - return loadStoredSwitchSelectionDisplay(allocator, codex_home, api_mode); + return loadStoredSwitchSelectionDisplayWithRefreshError(allocator, codex_home, api_mode, err); }, }; @@ -2281,6 +2428,7 @@ fn runSwitchLiveRefreshRound(runtime: *SwitchLiveRuntime) void { runtime.api_mode, runtime.target, runtime.strict_refresh, + runtime.debug_log, ) catch |err| { const finished_ms = nowMilliseconds(); const error_name = runtime.allocator.dupe(u8, @errorName(err)) catch null; @@ -2305,7 +2453,7 @@ fn runSwitchLiveRefreshRound(runtime: *SwitchLiveRuntime) void { runtime.refresh_interval_ms = loaded.policy.interval_ms; runtime.mode_label = loaded.policy.label; if (runtime.last_refresh_error_name) |name| runtime.allocator.free(name); - runtime.last_refresh_error_name = null; + runtime.last_refresh_error_name = loaded.refresh_error_name; runtime.last_refresh_finished_at_ms = finished_ms; runtime.last_refresh_duration_ms = finished_ms - (runtime.last_refresh_started_at_ms orelse started_ms); runtime.next_refresh_not_before_ms = finished_ms + runtime.refresh_interval_ms; @@ -2337,9 +2485,9 @@ fn loadSwitchSelectionDisplayLenient( api_mode: cli.ApiMode, target: ForegroundUsageRefreshTarget, ) !SwitchLoadedDisplay { - return loadSwitchSelectionDisplay(allocator, codex_home, api_mode, target, false) catch |err| switch (err) { + return loadSwitchSelectionDisplay(allocator, codex_home, api_mode, target, false, null) catch |err| switch (err) { error.OutOfMemory => return err, - else => try loadStoredSwitchSelectionDisplay(allocator, codex_home, api_mode), + else => try loadStoredSwitchSelectionDisplayWithRefreshError(allocator, codex_home, api_mode, err), }; } @@ -2466,6 +2614,7 @@ fn switchLiveRuntimeApplySelection( runtime.api_mode, runtime.target, ); + runtime.replaceLastRefreshError(loaded.refresh_error_name); return .{ .updated_display = loaded.display, .action_message = try std.fmt.allocPrint(allocator, "Switched to {s}", .{label}), @@ -2497,6 +2646,7 @@ fn removeLiveRuntimeApplySelection( runtime.api_mode, runtime.target, ); + runtime.replaceLastRefreshError(loaded.refresh_error_name); return .{ .updated_display = loaded.display, .action_message = try allocator.dupe(u8, "No matching accounts selected"), @@ -2517,6 +2667,7 @@ fn removeLiveRuntimeApplySelection( runtime.api_mode, runtime.target, ); + runtime.replaceLastRefreshError(loaded.refresh_error_name); return .{ .updated_display = loaded.display, .action_message = try buildRemoveSummaryMessageAlloc(allocator, removed_labels.items), @@ -2726,6 +2877,7 @@ fn handleRemove(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. opts.api_mode, .remove_account, strict_refresh, + null, ); var initial_display: ?cli.OwnedSwitchSelectionDisplay = loaded.display; errdefer if (initial_display) |*display| display.deinit(live_allocator); @@ -2737,6 +2889,8 @@ fn handleRemove(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. opts.api_mode, strict_refresh, loaded.policy, + loaded.refresh_error_name, + null, ); defer runtime.deinit(); diff --git a/src/tests/cli_bdd_test.zig b/src/tests/cli_bdd_test.zig index 2302ddd..302b732 100644 --- a/src/tests/cli_bdd_test.zig +++ b/src/tests/cli_bdd_test.zig @@ -300,7 +300,7 @@ test "Scenario: Given help when rendering then login and command help notes are try std.testing.expect(std.mem.indexOf(u8, help, "`config api enable` may trigger OpenAI account restrictions or suspension in some environments.") != null); try std.testing.expect(std.mem.indexOf(u8, help, "login") != null); try std.testing.expect(std.mem.indexOf(u8, help, "clean") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "switch [--live] [--api|--skip-api] | switch ") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "switch [--live] [--auto] [--api|--skip-api] | switch ") != null); try std.testing.expect(std.mem.indexOf(u8, help, "remove [--live] [...] | remove --all") != null); try std.testing.expect(std.mem.indexOf(u8, help, "Delete backup and stale files under accounts/") != null); try std.testing.expect(std.mem.indexOf(u8, help, "status") != null); @@ -754,6 +754,34 @@ test "Scenario: Given switch interactive with live flag when parsing then live m } } +test "Scenario: Given switch interactive with live auto flags when parsing then auto mode is preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "switch", "--live", "--auto" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .switch_account => |opts| { + try std.testing.expect(opts.live); + try std.testing.expect(opts.auto); + try std.testing.expect(opts.query == null); + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given switch with auto flag but without live when parsing then usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "switch", "--auto" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + try expectUsageError(result, .switch_account, "requires `--live`"); +} + test "Scenario: Given switch query with live flag when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "switch", "--live", "02" }; @@ -772,6 +800,15 @@ test "Scenario: Given switch query with api flag when parsing then usage error i try expectUsageError(result, .switch_account, "does not support"); } +test "Scenario: Given switch query with auto flag when parsing then usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "switch", "--live", "--auto", "02" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + try expectUsageError(result, .switch_account, "does not support"); +} + test "Scenario: Given switch with duplicate target when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "switch", "a@example.com", "b@example.com" }; diff --git a/src/tests/e2e_cli_test.zig b/src/tests/e2e_cli_test.zig index 8325a13..f6c08c0 100644 --- a/src/tests/e2e_cli_test.zig +++ b/src/tests/e2e_cli_test.zig @@ -1522,7 +1522,7 @@ test "Scenario: Given switch query with api flag when running switch then it ret try expectFailure(result); try std.testing.expectEqualStrings("", result.stdout); - try std.testing.expect(std.mem.indexOf(u8, result.stderr, "does not support `--live`, `--api`, or `--skip-api`") != null); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, "does not support `--live`, `--auto`, `--api`, or `--skip-api`") != null); } test "Scenario: Given switch query with skip-api flag when running switch then it returns a usage error" { @@ -1558,7 +1558,7 @@ test "Scenario: Given switch query with skip-api flag when running switch then i try expectFailure(result); try std.testing.expectEqualStrings("", result.stdout); - try std.testing.expect(std.mem.indexOf(u8, result.stderr, "does not support `--live`, `--api`, or `--skip-api`") != null); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, "does not support `--live`, `--auto`, `--api`, or `--skip-api`") != null); } test "Scenario: Given switch with skip-api when running interactively then it does not require api refresh executables" { From acb4a3ba01b6b33f24612b9bc7375a8d55421fb6 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Mon, 20 Apr 2026 18:49:01 +0800 Subject: [PATCH 09/11] fix: preserve remove api fallback --- fallback.md | 13 +++++ src/cli.zig | 70 +++++++++++++++++++++++++-- src/main.zig | 99 +++++++++++++++++++++++++++++++++----- src/tests/e2e_cli_test.zig | 9 ++-- 4 files changed, 172 insertions(+), 19 deletions(-) create mode 100644 fallback.md diff --git a/fallback.md b/fallback.md new file mode 100644 index 0000000..37f947e --- /dev/null +++ b/fallback.md @@ -0,0 +1,13 @@ +# Fallbacks + +## Live refresh falls back to stored registry data + +- Reason: `list --live`, `switch --live`, and `remove --live` in the default API mode still need a usable selector when Node or the upstream APIs are unavailable. +- Protected callers or data: interactive live-mode CLI users and the persisted registry snapshots under the active Codex home. +- Removal conditions: remove this fallback only if live mode is intentionally changed to fail closed, or if the default live mode becomes strict/API-only. + +## `remove --api` falls back to the local picker on refresh failures + +- Reason: interactive `remove --api` is documented as a best-effort foreground refresh, so users must still be able to delete stored accounts when Node setup or the foreground refresh path fails. +- Protected callers or data: users invoking `codex-auth remove --api` and the persisted `registry.json` entries they may need to remove even when live refresh is unavailable. +- Removal conditions: remove this fallback only if `remove --api` is intentionally changed to fail closed and the CLI/docs are updated to describe the strict behavior. diff --git a/src/cli.zig b/src/cli.zig index 023d712..7d31b74 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -1884,7 +1884,10 @@ pub fn selectAccountWithUsageOverrides( )) { return selectWithNumbers(allocator, reg, usage_overrides); } - return try selectInteractive(allocator, reg, usage_overrides); + return selectInteractive(allocator, reg, usage_overrides) catch |err| switch (err) { + error.TuiRequiresTty => selectWithNumbers(allocator, reg, usage_overrides), + else => return err, + }; } pub const SwitchSelectionDisplay = struct { @@ -2893,7 +2896,10 @@ pub fn selectAccountFromIndicesWithUsageOverrides( )) { return selectWithNumbersFromIndices(allocator, reg, indices, usage_overrides); } - return try selectInteractiveFromIndices(allocator, reg, indices, usage_overrides); + return selectInteractiveFromIndices(allocator, reg, indices, usage_overrides) catch |err| switch (err) { + error.TuiRequiresTty => selectWithNumbersFromIndices(allocator, reg, indices, usage_overrides), + else => return err, + }; } pub fn shouldUseNumberedSwitchSelector(is_windows: bool, stdin_is_tty: bool, stdout_is_tty: bool) bool { @@ -2917,7 +2923,10 @@ pub fn selectAccountsToRemoveWithUsageOverrides( )) { return selectRemoveWithNumbers(allocator, reg, usage_overrides); } - return try selectRemoveInteractive(allocator, reg, usage_overrides); + return selectRemoveInteractive(allocator, reg, usage_overrides) catch |err| switch (err) { + error.TuiRequiresTty => selectRemoveWithNumbers(allocator, reg, usage_overrides), + else => return err, + }; } pub fn shouldUseNumberedRemoveSelector(is_windows: bool, stdin_is_tty: bool, stdout_is_tty: bool) bool { @@ -3950,6 +3959,22 @@ fn renderSwitchScreen( } } +const TuiStatusSections = struct { + summary: []const u8, + details: []const u8, +}; + +fn splitTuiStatusLine(status_line: []const u8) TuiStatusSections { + const newline_idx = std.mem.indexOfScalar(u8, status_line, '\n') orelse return .{ + .summary = status_line, + .details = "", + }; + return .{ + .summary = status_line[0..newline_idx], + .details = status_line[newline_idx + 1 ..], + }; +} + fn renderListScreen( out: *std.Io.Writer, reg: *registry.Registry, @@ -3959,16 +3984,24 @@ fn renderListScreen( use_color: bool, status_line: []const u8, ) !void { + const status_sections = splitTuiStatusLine(status_line); + try out.writeAll("Live account list:\n\n"); try renderSwitchList(out, reg, rows, idx_width, widths, null, use_color); try out.writeAll("\n"); - if (status_line.len != 0) { + if (status_sections.summary.len != 0) { if (use_color) try out.writeAll(ansi.dim); - try out.writeAll(status_line); + try out.writeAll(status_sections.summary); try out.writeAll("\n"); if (use_color) try out.writeAll(ansi.reset); } try writeListTuiFooter(out, use_color); + if (status_sections.details.len != 0) { + if (use_color) try out.writeAll(ansi.dim); + try out.writeAll(status_sections.details); + try out.writeAll("\n"); + if (use_color) try out.writeAll(ansi.reset); + } } fn renderRemoveScreen( @@ -4825,6 +4858,33 @@ test "Scenario: Given switch live feedback when rendering switch screen then the try std.testing.expect(action_pos > footer_pos); } +test "Scenario: Given live list debug details when rendering then they stay below the footer" { + const gpa = std.testing.allocator; + var reg = makeTestRegistry(); + defer reg.deinit(gpa); + + try appendTestAccount(gpa, ®, "user-1::acc-1", "healthy@example.com", "", .team); + var rows = try buildSwitchRows(gpa, ®); + defer rows.deinit(gpa); + + var buffer: [2048]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buffer); + try renderListScreen( + &writer, + ®, + rows.items, + @max(@as(usize, 2), indexWidth(accountRowCount(rows.items))), + rows.widths, + false, + "Live refresh: api | Refresh in 9s\n[debug] request usage: healthy@example.com account_id=acc-1", + ); + + const output = writer.buffered(); + const footer_pos = std.mem.indexOf(u8, output, "Keys: Esc or q quit") orelse return error.TestExpectedEqual; + const debug_pos = std.mem.indexOf(u8, output, "[debug] request usage: healthy@example.com account_id=acc-1") orelse return error.TestExpectedEqual; + try std.testing.expect(debug_pos > footer_pos); +} + test "Scenario: Given usage overrides when rendering remove list then failed rows show response status in both usage columns" { const gpa = std.testing.allocator; var reg = makeTestRegistry(); diff --git a/src/main.zig b/src/main.zig index 7de2c8e..78972de 100644 --- a/src/main.zig +++ b/src/main.zig @@ -187,11 +187,12 @@ const SwitchLiveDebugLog = struct { } fn deinit(self: *@This()) void { - self.mutex.lockUncancelable(app_runtime.io()); - defer self.mutex.unlock(app_runtime.io()); + const io = app_runtime.io(); + self.mutex.lockUncancelable(io); for (self.lines.items) |line| self.allocator.free(line); self.lines.deinit(self.allocator); + self.mutex.unlock(io); self.* = undefined; } @@ -2916,22 +2917,29 @@ fn handleRemove(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. return; } - const usage_api_enabled = if (interactive_remove) apiModeUsesApi(reg.api.usage, opts.api_mode) else false; - const account_api_enabled = if (interactive_remove) apiModeUsesApi(reg.api.account, opts.api_mode) else false; + var usage_api_enabled = if (interactive_remove) apiModeUsesApi(reg.api.usage, opts.api_mode) else false; + var account_api_enabled = if (interactive_remove) apiModeUsesApi(reg.api.account, opts.api_mode) else false; var usage_state: ?ForegroundUsageRefreshState = null; defer if (usage_state) |*state| state.deinit(allocator); if (interactive_remove) { if (opts.api_mode == .force_api) { - try ensureForegroundNodeAvailableWithApiEnabled( + ensureForegroundNodeAvailableWithApiEnabled( allocator, codex_home, ®, .remove_account, usage_api_enabled, account_api_enabled, - ); + ) catch |err| switch (err) { + error.OutOfMemory => return err, + else => { + // Keep `remove --api` best-effort when refresh setup is unavailable. + usage_api_enabled = false; + account_api_enabled = false; + }, + }; } usage_state = refreshForegroundUsageForDisplayWithBatchFetcherAndDebugUsingApiEnabledWithBatchFailurePolicy( @@ -2940,10 +2948,10 @@ fn handleRemove(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. ®, null, usage_api_enabled, - opts.api_mode == .force_api, + false, ) catch |err| switch (err) { error.OutOfMemory => return err, - else => if (opts.api_mode == .force_api) return err else null, + else => null, }; maybeRefreshForegroundAccountNamesWithAccountApiEnabled( @@ -2955,9 +2963,7 @@ fn handleRemove(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. account_api_enabled, ) catch |err| switch (err) { error.OutOfMemory => return err, - else => { - if (opts.api_mode == .force_api) return err; - }, + else => {}, }; } @@ -3112,6 +3118,77 @@ test "handled cli errors include missing node" { try std.testing.expect(isHandledCliError(error.NodeJsRequired)); } +test "live fallback display preserves the refresh error name" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try app_runtime.realPathFileAlloc(gpa, tmp.dir, "."); + defer gpa.free(codex_home); + + var reg: registry.Registry = .{ + .schema_version = registry.current_schema_version, + .active_account_key = null, + .active_account_activated_at_ms = null, + .auto_switch = registry.defaultAutoSwitchConfig(), + .api = registry.defaultApiConfig(), + .accounts = std.ArrayList(registry.AccountRecord).empty, + }; + defer reg.deinit(gpa); + try registry.saveRegistry(gpa, codex_home, ®); + + var loaded = try loadStoredSwitchSelectionDisplayWithRefreshError(gpa, codex_home, .default, error.NodeJsRequired); + defer loaded.display.deinit(gpa); + defer if (loaded.refresh_error_name) |name| gpa.free(name); + + try std.testing.expectEqualStrings("NodeJsRequired", loaded.refresh_error_name.?); +} + +test "live status line includes the latest debug details below the summary" { + const gpa = std.testing.allocator; + + var dummy_reg: registry.Registry = .{ + .schema_version = registry.current_schema_version, + .active_account_key = null, + .active_account_activated_at_ms = null, + .auto_switch = registry.defaultAutoSwitchConfig(), + .api = registry.defaultApiConfig(), + .accounts = std.ArrayList(registry.AccountRecord).empty, + }; + defer dummy_reg.deinit(gpa); + + var debug_log = SwitchLiveDebugLog.init(gpa); + defer debug_log.deinit(); + try debug_log.appendLine("[debug] request usage: alpha@example.com account_id=acc-1"); + + var runtime = SwitchLiveRuntime.init( + gpa, + "", + .list, + .default, + false, + .{ + .usage_api_enabled = true, + .account_api_enabled = false, + .interval_ms = switch_live_api_refresh_interval_ms, + .label = "api", + }, + try gpa.dupe(u8, "NodeJsRequired"), + &debug_log, + ); + defer runtime.deinit(); + + const status_line = try runtime.buildStatusLine(gpa, .{ + .reg = &dummy_reg, + .usage_overrides = null, + }); + defer gpa.free(status_line); + + try std.testing.expect(std.mem.indexOf(u8, status_line, "Live refresh: api |") != null); + try std.testing.expect(std.mem.indexOf(u8, status_line, "Error: NodeJsRequired") != null); + try std.testing.expect(std.mem.indexOf(u8, status_line, "\n[debug] request usage: alpha@example.com account_id=acc-1") != null); +} + // Tests live in separate files but are pulled in by main.zig for zig test. test { _ = @import("tests/auth_test.zig"); diff --git a/src/tests/e2e_cli_test.zig b/src/tests/e2e_cli_test.zig index f6c08c0..4b2cdf7 100644 --- a/src/tests/e2e_cli_test.zig +++ b/src/tests/e2e_cli_test.zig @@ -1852,7 +1852,7 @@ test "Scenario: Given remove query with skip-api flag when running remove then i try std.testing.expect(std.mem.indexOf(u8, result.stderr, "do not support `--live`, `--api`, or `--skip-api`") != null); } -test "Scenario: Given interactive remove with api flag and missing refresh executables when running remove then it requires api refresh executables" { +test "Scenario: Given interactive remove with api flag and missing refresh executables when running remove then it falls back to the local picker" { const gpa = std.testing.allocator; const project_root = try projectRootAlloc(gpa); defer gpa.free(project_root); @@ -1887,12 +1887,15 @@ test "Scenario: Given interactive remove with api flag and missing refresh execu defer gpa.free(result.stdout); defer gpa.free(result.stderr); - try expectFailure(result); + try expectSuccess(result); + try std.testing.expect(std.mem.indexOf(u8, result.stdout, "Select accounts to delete:\n\n") != null); + try std.testing.expect(std.mem.indexOf(u8, result.stdout, "Removed 1 account(s): beta@example.com\n") != null); try std.testing.expect(std.mem.indexOf(u8, result.stderr, "Node.js 22+") != null); var loaded = try registry.loadRegistry(gpa, codex_home); defer loaded.deinit(gpa); - try std.testing.expectEqual(@as(usize, 2), loaded.accounts.items.len); + try std.testing.expectEqual(@as(usize, 1), loaded.accounts.items.len); + try std.testing.expectEqualStrings("alpha@example.com", loaded.accounts.items[0].email); } test "Scenario: Given remove without selectors when running remove then it does not require api refresh executables" { From 185b1d366d439d21bb07a62303e0661b43426d88 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Mon, 20 Apr 2026 19:04:21 +0800 Subject: [PATCH 10/11] fix: align foreground selector api modes --- src/cli.zig | 63 ++---------- src/main.zig | 195 +++---------------------------------- src/tests/cli_bdd_test.zig | 11 ++- src/tests/e2e_cli_test.zig | 1 - 4 files changed, 34 insertions(+), 236 deletions(-) diff --git a/src/cli.zig b/src/cli.zig index 7d31b74..42dae07 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -755,6 +755,9 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Pars } return usageErrorResult(allocator, .list, "unexpected argument `{s}` for `list`.", .{arg}); } + if (opts.debug and opts.live) { + return usageErrorResult(allocator, .list, "`--debug` cannot be combined with `--live` for `list`.", .{}); + } return .{ .command = .{ .list = opts } }; } @@ -1420,7 +1423,7 @@ fn writeUsageSection(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth --help\n"); try out.writeAll(" codex-auth help \n"); }, - .list => try out.writeAll(" codex-auth list [--debug] [--live] [--api|--skip-api]\n"), + .list => try out.writeAll(" codex-auth list [--debug|--live] [--api|--skip-api]\n"), .status => try out.writeAll(" codex-auth status\n"), .login => { try out.writeAll(" codex-auth login\n"); @@ -3959,22 +3962,6 @@ fn renderSwitchScreen( } } -const TuiStatusSections = struct { - summary: []const u8, - details: []const u8, -}; - -fn splitTuiStatusLine(status_line: []const u8) TuiStatusSections { - const newline_idx = std.mem.indexOfScalar(u8, status_line, '\n') orelse return .{ - .summary = status_line, - .details = "", - }; - return .{ - .summary = status_line[0..newline_idx], - .details = status_line[newline_idx + 1 ..], - }; -} - fn renderListScreen( out: *std.Io.Writer, reg: *registry.Registry, @@ -3984,24 +3971,16 @@ fn renderListScreen( use_color: bool, status_line: []const u8, ) !void { - const status_sections = splitTuiStatusLine(status_line); - try out.writeAll("Live account list:\n\n"); try renderSwitchList(out, reg, rows, idx_width, widths, null, use_color); try out.writeAll("\n"); - if (status_sections.summary.len != 0) { + if (status_line.len != 0) { if (use_color) try out.writeAll(ansi.dim); - try out.writeAll(status_sections.summary); + try out.writeAll(status_line); try out.writeAll("\n"); if (use_color) try out.writeAll(ansi.reset); } try writeListTuiFooter(out, use_color); - if (status_sections.details.len != 0) { - if (use_color) try out.writeAll(ansi.dim); - try out.writeAll(status_sections.details); - try out.writeAll("\n"); - if (use_color) try out.writeAll(ansi.reset); - } } fn renderRemoveScreen( @@ -4823,8 +4802,9 @@ test "Scenario: Given usage overrides when rendering switch list then errored ro try renderSwitchList(&writer, ®, rows.items, idx_width, rows.widths, null, false); const output = writer.buffered(); - try std.testing.expect(std.mem.indexOf(u8, output, "01 healthy@example.com") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "01 ") != null); try std.testing.expect(std.mem.indexOf(u8, output, "02 ") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "healthy@example.com") != null); try std.testing.expect(std.mem.indexOf(u8, output, "failed@example.com") != null); } @@ -4858,33 +4838,6 @@ test "Scenario: Given switch live feedback when rendering switch screen then the try std.testing.expect(action_pos > footer_pos); } -test "Scenario: Given live list debug details when rendering then they stay below the footer" { - const gpa = std.testing.allocator; - var reg = makeTestRegistry(); - defer reg.deinit(gpa); - - try appendTestAccount(gpa, ®, "user-1::acc-1", "healthy@example.com", "", .team); - var rows = try buildSwitchRows(gpa, ®); - defer rows.deinit(gpa); - - var buffer: [2048]u8 = undefined; - var writer: std.Io.Writer = .fixed(&buffer); - try renderListScreen( - &writer, - ®, - rows.items, - @max(@as(usize, 2), indexWidth(accountRowCount(rows.items))), - rows.widths, - false, - "Live refresh: api | Refresh in 9s\n[debug] request usage: healthy@example.com account_id=acc-1", - ); - - const output = writer.buffered(); - const footer_pos = std.mem.indexOf(u8, output, "Keys: Esc or q quit") orelse return error.TestExpectedEqual; - const debug_pos = std.mem.indexOf(u8, output, "[debug] request usage: healthy@example.com account_id=acc-1") orelse return error.TestExpectedEqual; - try std.testing.expect(debug_pos > footer_pos); -} - test "Scenario: Given usage overrides when rendering remove list then failed rows show response status in both usage columns" { const gpa = std.testing.allocator; var reg = makeTestRegistry(); diff --git a/src/main.zig b/src/main.zig index 78972de..af24770 100644 --- a/src/main.zig +++ b/src/main.zig @@ -18,7 +18,6 @@ const disable_background_account_name_refresh_env = "CODEX_AUTH_DISABLE_BACKGROU const foreground_usage_refresh_concurrency: usize = 5; const switch_live_api_refresh_interval_ms: i64 = 30_000; const switch_live_local_refresh_interval_ms: i64 = 10_000; -const switch_live_debug_max_lines: usize = 8; fn getEnvMap(allocator: std.mem.Allocator) !std.process.Environ.Map { return try app_runtime.currentEnviron().createMap(allocator); @@ -130,15 +129,8 @@ const DebugUsageLabelState = struct { } }; -const ForegroundUsageDebugMirror = struct { - context: *anyopaque, - append_line: *const fn (context: *anyopaque, line: []const u8) anyerror!void, -}; - pub const ForegroundUsageDebugLogger = struct { - allocator: std.mem.Allocator = std.heap.smp_allocator, writer: ?*std.Io.Writer, - mirror: ?ForegroundUsageDebugMirror = null, mutex: std.Io.Mutex = .init, pub fn init(writer: *std.Io.Writer) ForegroundUsageDebugLogger { @@ -147,18 +139,6 @@ pub const ForegroundUsageDebugLogger = struct { }; } - pub fn initMirrored( - allocator: std.mem.Allocator, - writer: ?*std.Io.Writer, - mirror: ForegroundUsageDebugMirror, - ) ForegroundUsageDebugLogger { - return .{ - .allocator = allocator, - .writer = writer, - .mirror = mirror, - }; - } - pub fn print(self: *ForegroundUsageDebugLogger, comptime fmt: []const u8, args: anytype) !void { self.mutex.lockUncancelable(app_runtime.io()); defer self.mutex.unlock(app_runtime.io()); @@ -167,79 +147,9 @@ pub const ForegroundUsageDebugLogger = struct { try writer.print(fmt, args); try writer.flush(); } - if (self.mirror) |mirror| { - const line = try std.fmt.allocPrint(self.allocator, fmt, args); - defer self.allocator.free(line); - try mirror.append_line(mirror.context, std.mem.trim(u8, line, "\r\n")); - } - } -}; - -const SwitchLiveDebugLog = struct { - allocator: std.mem.Allocator, - lines: std.ArrayList([]u8) = .empty, - mutex: std.Io.Mutex = .init, - - fn init(allocator: std.mem.Allocator) @This() { - return .{ - .allocator = allocator, - }; - } - - fn deinit(self: *@This()) void { - const io = app_runtime.io(); - self.mutex.lockUncancelable(io); - - for (self.lines.items) |line| self.allocator.free(line); - self.lines.deinit(self.allocator); - self.mutex.unlock(io); - self.* = undefined; - } - - fn mirror(self: *@This()) ForegroundUsageDebugMirror { - return .{ - .context = @ptrCast(self), - .append_line = switchLiveDebugLogAppendLine, - }; - } - - fn appendLine(self: *@This(), line: []const u8) !void { - const trimmed = std.mem.trim(u8, line, "\r\n"); - if (trimmed.len == 0) return; - - self.mutex.lockUncancelable(app_runtime.io()); - defer self.mutex.unlock(app_runtime.io()); - - if (self.lines.items.len == switch_live_debug_max_lines) { - self.allocator.free(self.lines.orderedRemove(0)); - } - const owned = try self.allocator.dupe(u8, trimmed); - errdefer self.allocator.free(owned); - try self.lines.append(self.allocator, owned); - } - - fn buildTextAlloc(self: *@This(), allocator: std.mem.Allocator) !?[]u8 { - self.mutex.lockUncancelable(app_runtime.io()); - defer self.mutex.unlock(app_runtime.io()); - - if (self.lines.items.len == 0) return null; - - var out: std.Io.Writer.Allocating = .init(allocator); - errdefer out.deinit(); - - for (self.lines.items, 0..) |line, idx| { - if (idx != 0) try out.writer.writeAll("\n"); - try out.writer.writeAll(line); - } - return try out.toOwnedSlice(); } }; -fn switchLiveDebugLogAppendLine(context: *anyopaque, line: []const u8) !void { - const debug_log: *SwitchLiveDebugLog = @ptrCast(@alignCast(context)); - try debug_log.appendLine(line); -} - const ForegroundUsageDebugContext = struct { logger: *ForegroundUsageDebugLogger, label_state: *const DebugUsageLabelState, @@ -338,7 +248,7 @@ pub const ForegroundUsageRefreshTarget = enum { }; pub fn shouldRefreshForegroundUsage(target: ForegroundUsageRefreshTarget) bool { - return target == .list or target == .switch_account or target == .remove_account; + return target == .list or target == .switch_account; } fn apiModeUsesApi(default_enabled: bool, api_mode: cli.ApiMode) bool { @@ -1640,15 +1550,12 @@ fn handleList(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.Li if (opts.live) { const live_allocator = std.heap.smp_allocator; const strict_refresh = opts.api_mode == .force_api; - var debug_log = SwitchLiveDebugLog.init(live_allocator); - defer debug_log.deinit(); const loaded = try loadSwitchSelectionDisplay( live_allocator, codex_home, opts.api_mode, .list, strict_refresh, - if (opts.debug) &debug_log else null, ); var initial_display: ?cli.OwnedSwitchSelectionDisplay = loaded.display; errdefer if (initial_display) |*display| display.deinit(live_allocator); @@ -1661,7 +1568,6 @@ fn handleList(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.Li strict_refresh, loaded.policy, loaded.refresh_error_name, - if (opts.debug) &debug_log else null, ); defer runtime.deinit(); @@ -1830,14 +1736,16 @@ fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. } if (!opts.live) { - var loaded = try loadSwitchSelectionDisplay( - allocator, - codex_home, - opts.api_mode, - .switch_account, - true, - null, - ); + var loaded = if (opts.api_mode == .skip_api) + try loadStoredSwitchSelectionDisplay(allocator, codex_home, opts.api_mode) + else + try loadSwitchSelectionDisplay( + allocator, + codex_home, + opts.api_mode, + .switch_account, + true, + ); defer loaded.display.deinit(allocator); defer if (loaded.refresh_error_name) |name| allocator.free(name); @@ -1866,7 +1774,6 @@ fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. opts.api_mode, .switch_account, strict_refresh, - null, ); var initial_display: ?cli.OwnedSwitchSelectionDisplay = loaded.display; errdefer if (initial_display) |*display| display.deinit(live_allocator); @@ -1879,7 +1786,6 @@ fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. strict_refresh, loaded.policy, loaded.refresh_error_name, - null, ); defer runtime.deinit(); @@ -1936,7 +1842,6 @@ const SwitchLiveRuntime = struct { last_refresh_error_name: ?[]u8 = null, refresh_interval_ms: i64, mode_label: []const u8, - debug_log: ?*SwitchLiveDebugLog = null, fn init( allocator: std.mem.Allocator, @@ -1946,7 +1851,6 @@ const SwitchLiveRuntime = struct { strict_refresh: bool, initial_policy: SwitchLiveRefreshPolicy, initial_refresh_error_name: ?[]u8, - debug_log: ?*SwitchLiveDebugLog, ) @This() { const io_impl = std.Io.Threaded.init(allocator, .{ .concurrent_limit = .limited(1), @@ -1963,7 +1867,6 @@ const SwitchLiveRuntime = struct { .refresh_interval_ms = initial_policy.interval_ms, .mode_label = initial_policy.label, .last_refresh_error_name = initial_refresh_error_name, - .debug_log = debug_log, }; } @@ -2090,23 +1993,11 @@ const SwitchLiveRuntime = struct { try allocator.dupe(u8, ""); defer allocator.free(error_suffix); - const base_status_line = try std.fmt.allocPrint( + return std.fmt.allocPrint( allocator, "Live refresh: {s} | {s}{s}", .{ mode_label, refresh_state, error_suffix }, ); - errdefer allocator.free(base_status_line); - - const debug_text = if (self.debug_log) |debug_log| - try debug_log.buildTextAlloc(allocator) - else - null; - defer if (debug_text) |text| allocator.free(text); - - if (debug_text) |text| { - return std.fmt.allocPrint(allocator, "{s}\n{s}", .{ base_status_line, text }); - } - return base_status_line; } }; @@ -2323,7 +2214,6 @@ fn loadSwitchSelectionDisplay( api_mode: cli.ApiMode, target: ForegroundUsageRefreshTarget, strict_refresh: bool, - debug_log: ?*SwitchLiveDebugLog, ) !SwitchLoadedDisplay { var base = try registry.loadRegistry(allocator, codex_home); defer base.deinit(allocator); @@ -2349,11 +2239,6 @@ fn loadSwitchSelectionDisplay( }, }; - var debug_logger: ?ForegroundUsageDebugLogger = null; - if (debug_log) |log| { - debug_logger = ForegroundUsageDebugLogger.initMirrored(allocator, null, log.mirror()); - } - var usage_state = refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitAndDebugUsingApiEnabledAndPersist( allocator, codex_home, @@ -2361,7 +2246,7 @@ fn loadSwitchSelectionDisplay( usage_api.fetchUsageForAuthPathDetailed, usage_api.fetchUsageForAuthPathsDetailedBatch, initForegroundUsagePool, - if (debug_logger) |*logger| logger else null, + null, initial_policy.usage_api_enabled, false, false, @@ -2429,7 +2314,6 @@ fn runSwitchLiveRefreshRound(runtime: *SwitchLiveRuntime) void { runtime.api_mode, runtime.target, runtime.strict_refresh, - runtime.debug_log, ) catch |err| { const finished_ms = nowMilliseconds(); const error_name = runtime.allocator.dupe(u8, @errorName(err)) catch null; @@ -2486,7 +2370,7 @@ fn loadSwitchSelectionDisplayLenient( api_mode: cli.ApiMode, target: ForegroundUsageRefreshTarget, ) !SwitchLoadedDisplay { - return loadSwitchSelectionDisplay(allocator, codex_home, api_mode, target, false, null) catch |err| switch (err) { + return loadSwitchSelectionDisplay(allocator, codex_home, api_mode, target, false) catch |err| switch (err) { error.OutOfMemory => return err, else => try loadStoredSwitchSelectionDisplayWithRefreshError(allocator, codex_home, api_mode, err), }; @@ -2878,7 +2762,6 @@ fn handleRemove(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. opts.api_mode, .remove_account, strict_refresh, - null, ); var initial_display: ?cli.OwnedSwitchSelectionDisplay = loaded.display; errdefer if (initial_display) |*display| display.deinit(live_allocator); @@ -2891,7 +2774,6 @@ fn handleRemove(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. strict_refresh, loaded.policy, loaded.refresh_error_name, - null, ); defer runtime.deinit(); @@ -2917,8 +2799,8 @@ fn handleRemove(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. return; } - var usage_api_enabled = if (interactive_remove) apiModeUsesApi(reg.api.usage, opts.api_mode) else false; - var account_api_enabled = if (interactive_remove) apiModeUsesApi(reg.api.account, opts.api_mode) else false; + var usage_api_enabled = interactive_remove and opts.api_mode == .force_api; + var account_api_enabled = interactive_remove and opts.api_mode == .force_api; var usage_state: ?ForegroundUsageRefreshState = null; defer if (usage_state) |*state| state.deinit(allocator); @@ -3144,51 +3026,6 @@ test "live fallback display preserves the refresh error name" { try std.testing.expectEqualStrings("NodeJsRequired", loaded.refresh_error_name.?); } -test "live status line includes the latest debug details below the summary" { - const gpa = std.testing.allocator; - - var dummy_reg: registry.Registry = .{ - .schema_version = registry.current_schema_version, - .active_account_key = null, - .active_account_activated_at_ms = null, - .auto_switch = registry.defaultAutoSwitchConfig(), - .api = registry.defaultApiConfig(), - .accounts = std.ArrayList(registry.AccountRecord).empty, - }; - defer dummy_reg.deinit(gpa); - - var debug_log = SwitchLiveDebugLog.init(gpa); - defer debug_log.deinit(); - try debug_log.appendLine("[debug] request usage: alpha@example.com account_id=acc-1"); - - var runtime = SwitchLiveRuntime.init( - gpa, - "", - .list, - .default, - false, - .{ - .usage_api_enabled = true, - .account_api_enabled = false, - .interval_ms = switch_live_api_refresh_interval_ms, - .label = "api", - }, - try gpa.dupe(u8, "NodeJsRequired"), - &debug_log, - ); - defer runtime.deinit(); - - const status_line = try runtime.buildStatusLine(gpa, .{ - .reg = &dummy_reg, - .usage_overrides = null, - }); - defer gpa.free(status_line); - - try std.testing.expect(std.mem.indexOf(u8, status_line, "Live refresh: api |") != null); - try std.testing.expect(std.mem.indexOf(u8, status_line, "Error: NodeJsRequired") != null); - try std.testing.expect(std.mem.indexOf(u8, status_line, "\n[debug] request usage: alpha@example.com account_id=acc-1") != null); -} - // Tests live in separate files but are pulled in by main.zig for zig test. test { _ = @import("tests/auth_test.zig"); diff --git a/src/tests/cli_bdd_test.zig b/src/tests/cli_bdd_test.zig index 302b732..b5e7655 100644 --- a/src/tests/cli_bdd_test.zig +++ b/src/tests/cli_bdd_test.zig @@ -211,6 +211,15 @@ test "Scenario: Given list with live flag when parsing then live mode is preserv } } +test "Scenario: Given list with live and debug flags when parsing then usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "list", "--live", "--debug" }; + var result = try cli.parseArgs(gpa, &args); + defer cli.freeParseResult(gpa, &result); + + try expectUsageError(result, .list, "`--debug` cannot be combined with `--live`"); +} + test "Scenario: Given list with api flag when parsing then forced api mode is preserved" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "list", "--api" }; @@ -324,7 +333,7 @@ test "Scenario: Given simple command help when rendering then examples are omitt const help = aw.written(); try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth list") != null); try std.testing.expect(std.mem.indexOf(u8, help, "List available accounts.") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "Usage:\n codex-auth list [--debug] [--live] [--api|--skip-api]\n") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "Usage:\n codex-auth list [--debug|--live] [--api|--skip-api]\n") != null); try std.testing.expect(std.mem.indexOf(u8, help, "Examples:") == null); } diff --git a/src/tests/e2e_cli_test.zig b/src/tests/e2e_cli_test.zig index 4b2cdf7..76b21da 100644 --- a/src/tests/e2e_cli_test.zig +++ b/src/tests/e2e_cli_test.zig @@ -1890,7 +1890,6 @@ test "Scenario: Given interactive remove with api flag and missing refresh execu try expectSuccess(result); try std.testing.expect(std.mem.indexOf(u8, result.stdout, "Select accounts to delete:\n\n") != null); try std.testing.expect(std.mem.indexOf(u8, result.stdout, "Removed 1 account(s): beta@example.com\n") != null); - try std.testing.expect(std.mem.indexOf(u8, result.stderr, "Node.js 22+") != null); var loaded = try registry.loadRegistry(gpa, codex_home); defer loaded.deinit(gpa); From 96dbd02301059f05cc75b4c7a28cc4cd95c7b0c9 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Mon, 20 Apr 2026 19:32:59 +0800 Subject: [PATCH 11/11] fix: remove obsolete list debug mode --- README.md | 3 +- src/cli.zig | 18 +- src/main.zig | 403 ++----------------------------------- src/tests/cli_bdd_test.zig | 26 +-- src/tests/e2e_cli_test.zig | 31 --- src/tests/main_test.zig | 132 ------------ 6 files changed, 23 insertions(+), 590 deletions(-) diff --git a/README.md b/README.md index 65d2d08..7e3883a 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ Remove-Item "$env:LOCALAPPDATA\codex-auth\bin\codex-auth-auto.exe" -Force -Error | Command | Description | |---------|-------------| -| `codex-auth list [--debug] [--live] [--api|--skip-api]` | List all accounts. `--live` keeps refreshing the terminal view; `--api` forces remote refresh, while `--skip-api` forbids remote API use for this command. | +| `codex-auth list [--live] [--api|--skip-api]` | List all accounts. `--live` keeps refreshing the terminal view; `--api` forces remote refresh, while `--skip-api` forbids remote API use for this command. | | `codex-auth login [--device-auth]` | Run `codex login` (optionally with `--device-auth`), then add the current account | | `codex-auth switch [--live] [--auto] [--api|--skip-api]` | Switch the active account interactively. Without `--live` it exits after one switch; with `--live` it stays open and keeps refreshing. `--auto` requires `--live` and auto-switches away from the current account when the live view shows it as exhausted or returns a non-200 usage API status. | | `codex-auth switch ` | Switch the active account directly by row number, alias, or fuzzy match using stored local data only. | @@ -130,7 +130,6 @@ Remove-Item "$env:LOCALAPPDATA\codex-auth\bin\codex-auth-auto.exe" -Force -Error ```shell codex-auth list -codex-auth list --debug codex-auth list --live codex-auth list --api # force usage/team-name API refresh, even if config api is disabled codex-auth list --skip-api # forbid usage/team-name API refresh for this command diff --git a/src/cli.zig b/src/cli.zig index 42dae07..a78bf51 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -215,13 +215,13 @@ fn writeTuiResetFrameTo(out: *std.Io.Writer) !void { fn writeSwitchTuiFooter(out: *std.Io.Writer, use_color: bool) !void { if (use_color) try out.writeAll(ansi.dim); - try out.writeAll("Keys: ↑/↓ or j/k, 1-9 type, Backspace edit, Enter select, Esc or q quit\n"); + try out.writeAll("Keys: ↑/↓ or j/k, 1-9 type, Enter select, Esc or q quit\n"); if (use_color) try out.writeAll(ansi.reset); } fn writeRemoveTuiFooter(out: *std.Io.Writer, use_color: bool) !void { if (use_color) try out.writeAll(ansi.dim); - try out.writeAll("Keys: ↑/↓ or j/k move, Space toggle, 1-9 type, Backspace edit, Enter delete, Esc or q quit\n"); + try out.writeAll("Keys: ↑/↓ or j/k move, Space toggle, 1-9 type, Enter delete, Esc or q quit\n"); if (use_color) try out.writeAll(ansi.reset); } @@ -600,7 +600,6 @@ pub const ApiMode = enum { }; pub const ListOptions = struct { - debug: bool = false, live: bool = false, api_mode: ApiMode = .default, }; @@ -717,13 +716,6 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Pars var i: usize = 2; while (i < args.len) : (i += 1) { const arg = std.mem.sliceTo(args[i], 0); - if (std.mem.eql(u8, arg, "--debug")) { - if (opts.debug) { - return usageErrorResult(allocator, .list, "duplicate `--debug` for `list`.", .{}); - } - opts.debug = true; - continue; - } if (std.mem.eql(u8, arg, "--live")) { if (opts.live) { return usageErrorResult(allocator, .list, "duplicate `--live` for `list`.", .{}); @@ -755,9 +747,6 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Pars } return usageErrorResult(allocator, .list, "unexpected argument `{s}` for `list`.", .{arg}); } - if (opts.debug and opts.live) { - return usageErrorResult(allocator, .list, "`--debug` cannot be combined with `--live` for `list`.", .{}); - } return .{ .command = .{ .list = opts } }; } @@ -1423,7 +1412,7 @@ fn writeUsageSection(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth --help\n"); try out.writeAll(" codex-auth help \n"); }, - .list => try out.writeAll(" codex-auth list [--debug|--live] [--api|--skip-api]\n"), + .list => try out.writeAll(" codex-auth list [--live] [--api|--skip-api]\n"), .status => try out.writeAll(" codex-auth status\n"), .login => { try out.writeAll(" codex-auth login\n"); @@ -1469,7 +1458,6 @@ fn writeExamplesSection(out: *std.Io.Writer, topic: HelpTopic) !void { }, .list => { try out.writeAll(" codex-auth list\n"); - try out.writeAll(" codex-auth list --debug\n"); try out.writeAll(" codex-auth list --live\n"); try out.writeAll(" codex-auth list --api\n"); try out.writeAll(" codex-auth list --skip-api\n"); diff --git a/src/main.zig b/src/main.zig index af24770..a7c6fdf 100644 --- a/src/main.zig +++ b/src/main.zig @@ -9,7 +9,6 @@ const registry = @import("registry.zig"); const auth = @import("auth.zig"); const auto = @import("auto.zig"); const format = @import("format.zig"); -const io_util = @import("io_util.zig"); const usage_api = @import("usage_api.zig"); const skip_service_reconcile_env = "CODEX_AUTH_SKIP_SERVICE_RECONCILE"; @@ -119,42 +118,6 @@ const SwitchQueryResolution = union(enum) { } }; -const DebugUsageLabelState = struct { - labels: [][]const u8, - - fn deinit(self: *DebugUsageLabelState, allocator: std.mem.Allocator) void { - for (self.labels) |label| allocator.free(@constCast(label)); - allocator.free(self.labels); - self.* = undefined; - } -}; - -pub const ForegroundUsageDebugLogger = struct { - writer: ?*std.Io.Writer, - mutex: std.Io.Mutex = .init, - - pub fn init(writer: *std.Io.Writer) ForegroundUsageDebugLogger { - return .{ - .writer = writer, - }; - } - - pub fn print(self: *ForegroundUsageDebugLogger, comptime fmt: []const u8, args: anytype) !void { - self.mutex.lockUncancelable(app_runtime.io()); - defer self.mutex.unlock(app_runtime.io()); - - if (self.writer) |writer| { - try writer.print(fmt, args); - try writer.flush(); - } - } -}; - -const ForegroundUsageDebugContext = struct { - logger: *ForegroundUsageDebugLogger, - label_state: *const DebugUsageLabelState, -}; - pub fn main(init: std.process.Init.Minimal) !void { var exit_code: u8 = 0; runMain(init) catch |err| { @@ -405,14 +368,13 @@ pub fn refreshForegroundUsageForDisplayWithApiFetcher( reg: *registry.Registry, usage_fetcher: UsageFetchDetailedFn, ) !ForegroundUsageRefreshState { - return refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitAndDebugUsingApiEnabled( + return refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitUsingApiEnabled( allocator, codex_home, reg, usage_fetcher, null, initForegroundUsagePool, - null, reg.api.usage, false, ); @@ -423,63 +385,43 @@ pub fn refreshForegroundUsageForDisplay( codex_home: []const u8, reg: *registry.Registry, ) !ForegroundUsageRefreshState { - return refreshForegroundUsageForDisplayWithBatchFetcherAndDebugUsingApiEnabled( + return refreshForegroundUsageForDisplayWithBatchFetcherUsingApiEnabled( allocator, codex_home, reg, - null, reg.api.usage, ); } -pub fn refreshForegroundUsageForDisplayWithBatchFetcherAndDebug( +fn refreshForegroundUsageForDisplayWithBatchFetcherUsingApiEnabled( allocator: std.mem.Allocator, codex_home: []const u8, reg: *registry.Registry, - debug_logger: ?*ForegroundUsageDebugLogger, -) !ForegroundUsageRefreshState { - return refreshForegroundUsageForDisplayWithBatchFetcherAndDebugUsingApiEnabled( - allocator, - codex_home, - reg, - debug_logger, - reg.api.usage, - ); -} - -fn refreshForegroundUsageForDisplayWithBatchFetcherAndDebugUsingApiEnabled( - allocator: std.mem.Allocator, - codex_home: []const u8, - reg: *registry.Registry, - debug_logger: ?*ForegroundUsageDebugLogger, usage_api_enabled: bool, ) !ForegroundUsageRefreshState { - return refreshForegroundUsageForDisplayWithBatchFetcherAndDebugUsingApiEnabledWithBatchFailurePolicy( + return refreshForegroundUsageForDisplayWithBatchFetcherUsingApiEnabledWithBatchFailurePolicy( allocator, codex_home, reg, - debug_logger, usage_api_enabled, false, ); } -fn refreshForegroundUsageForDisplayWithBatchFetcherAndDebugUsingApiEnabledWithBatchFailurePolicy( +fn refreshForegroundUsageForDisplayWithBatchFetcherUsingApiEnabledWithBatchFailurePolicy( allocator: std.mem.Allocator, codex_home: []const u8, reg: *registry.Registry, - debug_logger: ?*ForegroundUsageDebugLogger, usage_api_enabled: bool, batch_fetch_failures_are_fatal: bool, ) !ForegroundUsageRefreshState { - return refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitAndDebugUsingApiEnabled( + return refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitUsingApiEnabled( allocator, codex_home, reg, usage_api.fetchUsageForAuthPathDetailed, usage_api.fetchUsageForAuthPathsDetailedBatch, initForegroundUsagePool, - debug_logger, usage_api_enabled, batch_fetch_failures_are_fatal, ); @@ -492,73 +434,48 @@ pub fn refreshForegroundUsageForDisplayWithApiFetcherWithPoolInit( usage_fetcher: UsageFetchDetailedFn, pool_init: ForegroundUsagePoolInitFn, ) !ForegroundUsageRefreshState { - return refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitAndDebugUsingApiEnabled( + return refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitUsingApiEnabled( allocator, codex_home, reg, usage_fetcher, null, pool_init, - null, reg.api.usage, false, ); } -pub fn refreshForegroundUsageForDisplayWithApiFetcherWithPoolInitAndDebug( - allocator: std.mem.Allocator, - codex_home: []const u8, - reg: *registry.Registry, - usage_fetcher: UsageFetchDetailedFn, - pool_init: ForegroundUsagePoolInitFn, - debug_logger: ?*ForegroundUsageDebugLogger, -) !ForegroundUsageRefreshState { - return refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitAndDebugUsingApiEnabled( - allocator, - codex_home, - reg, - usage_fetcher, - null, - pool_init, - debug_logger, - reg.api.usage, - false, - ); -} - -fn refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitAndDebugUsingApiEnabled( +fn refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitUsingApiEnabled( allocator: std.mem.Allocator, codex_home: []const u8, reg: *registry.Registry, usage_fetcher: UsageFetchDetailedFn, batch_fetcher: ?UsageBatchFetchDetailedFn, pool_init: ForegroundUsagePoolInitFn, - debug_logger: ?*ForegroundUsageDebugLogger, usage_api_enabled: bool, batch_fetch_failures_are_fatal: bool, ) !ForegroundUsageRefreshState { - return refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitAndDebugUsingApiEnabledAndPersist( + return refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitUsingApiEnabledAndPersist( allocator, codex_home, reg, usage_fetcher, batch_fetcher, pool_init, - debug_logger, usage_api_enabled, batch_fetch_failures_are_fatal, true, ); } -fn refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitAndDebugUsingApiEnabledAndPersist( +fn refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitUsingApiEnabledAndPersist( allocator: std.mem.Allocator, codex_home: []const u8, reg: *registry.Registry, usage_fetcher: UsageFetchDetailedFn, batch_fetcher: ?UsageBatchFetchDetailedFn, pool_init: ForegroundUsagePoolInitFn, - debug_logger: ?*ForegroundUsageDebugLogger, usage_api_enabled: bool, batch_fetch_failures_are_fatal: bool, persist_registry: bool, @@ -566,40 +483,15 @@ fn refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitAndDebugUsingApiEn var state = try initForegroundUsageRefreshState(allocator, reg.accounts.items.len); errdefer state.deinit(allocator); - var debug_label_state: ?DebugUsageLabelState = null; - defer if (debug_label_state) |*label_state| label_state.deinit(allocator); - - var debug_context: ?ForegroundUsageDebugContext = null; - if (!usage_api_enabled) { state.local_only_mode = true; if (try auto.refreshActiveUsage(allocator, codex_home, reg)) { if (persist_registry) try registry.saveRegistry(allocator, codex_home, reg); } - if (debug_logger) |logger| { - try logger.print("[debug] usage refresh skipped: mode=local-only; only the active account can refresh from local rollout data\n", .{}); - try printForegroundUsageDebugDone(logger, &state); - } - return state; - } - - if (reg.accounts.items.len == 0) { - if (debug_logger) |logger| { - try printForegroundUsageDebugDone(logger, &state); - } return state; } - if (debug_logger) |logger| { - debug_label_state = try buildDebugUsageLabelState(allocator, reg); - debug_context = .{ - .logger = logger, - .label_state = &debug_label_state.?, - }; - const node_executable = try chatgpt_http.resolveNodeExecutableForDebugAlloc(allocator); - defer allocator.free(node_executable); - try printForegroundUsageDebugStart(logger, reg.accounts.items.len, node_executable); - } + if (reg.accounts.items.len == 0) return state; const worker_results = try allocator.alloc(ForegroundUsageWorkerResult, reg.accounts.items.len); defer { @@ -616,9 +508,6 @@ fn refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitAndDebugUsingApiEn const auth_paths = try auth_path_arena.alloc([]const u8, reg.accounts.items.len); for (reg.accounts.items, 0..) |account, idx| { auth_paths[idx] = try registry.accountAuthPath(auth_path_arena, codex_home, account.account_key); - if (debug_context) |debug| { - try printForegroundUsageDebugRequest(debug.logger, reg, idx, debug.label_state.labels[idx]); - } } const batch_results = fetch_batch( @@ -631,16 +520,8 @@ fn refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitAndDebugUsingApiEn if (batch_fetch_failures_are_fatal) return err; const error_name = @errorName(err); for (worker_results, 0..) |*worker_result, idx| { + _ = idx; worker_result.* = .{ .error_name = error_name }; - if (debug_context) |debug| { - printForegroundUsageDebugWorkerResult( - auth_path_arena, - debug.logger, - debug.label_state.labels[idx], - reg.accounts.items[idx].last_usage, - worker_result.*, - ); - } } break :batch_fetch; }, @@ -658,16 +539,6 @@ fn refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitAndDebugUsingApiEn .snapshot = batch_result.snapshot, }; batch_result.snapshot = null; - - if (debug_context) |debug| { - printForegroundUsageDebugWorkerResult( - auth_path_arena, - debug.logger, - debug.label_state.labels[idx], - reg.accounts.items[idx].last_usage, - worker_results[idx], - ); - } } } else { var use_concurrent_usage_refresh = reg.accounts.items.len > 1; @@ -688,10 +559,9 @@ fn refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitAndDebugUsingApiEn reg, usage_fetcher, worker_results, - debug_context, ); } else { - runForegroundUsageRefreshWorkersSerially(allocator, codex_home, reg, usage_fetcher, worker_results, debug_context); + runForegroundUsageRefreshWorkersSerially(allocator, codex_home, reg, usage_fetcher, worker_results); } } @@ -731,10 +601,6 @@ fn refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitAndDebugUsingApiEn try registry.saveRegistry(allocator, codex_home, reg); } - if (debug_logger) |logger| { - try printForegroundUsageDebugDone(logger, &state); - } - return state; } @@ -752,7 +618,6 @@ const ForegroundUsageWorkerQueue = struct { reg: *registry.Registry, usage_fetcher: UsageFetchDetailedFn, results: []ForegroundUsageWorkerResult, - debug_context: ?ForegroundUsageDebugContext, next_index: std.atomic.Value(usize) = .init(0), fn run(self: *ForegroundUsageWorkerQueue) void { @@ -760,9 +625,6 @@ const ForegroundUsageWorkerQueue = struct { const idx = self.next_index.fetchAdd(1, .monotonic); if (idx >= self.reg.accounts.items.len) return; - if (self.debug_context) |debug| { - printForegroundUsageDebugRequest(debug.logger, self.reg, idx, debug.label_state.labels[idx]) catch {}; - } foregroundUsageRefreshWorker( self.allocator, self.codex_home, @@ -770,7 +632,6 @@ const ForegroundUsageWorkerQueue = struct { idx, self.usage_fetcher, self.results, - self.debug_context, ); } } @@ -782,11 +643,10 @@ fn runForegroundUsageRefreshWorkersConcurrently( reg: *registry.Registry, usage_fetcher: UsageFetchDetailedFn, results: []ForegroundUsageWorkerResult, - debug_context: ?ForegroundUsageDebugContext, ) !void { const worker_count = @min(reg.accounts.items.len, foreground_usage_refresh_concurrency); if (worker_count <= 1) { - runForegroundUsageRefreshWorkersSerially(allocator, codex_home, reg, usage_fetcher, results, debug_context); + runForegroundUsageRefreshWorkersSerially(allocator, codex_home, reg, usage_fetcher, results); return; } @@ -796,7 +656,6 @@ fn runForegroundUsageRefreshWorkersConcurrently( .reg = reg, .usage_fetcher = usage_fetcher, .results = results, - .debug_context = debug_context, }; const helper_count = worker_count - 1; @@ -825,13 +684,9 @@ fn runForegroundUsageRefreshWorkersSerially( reg: *registry.Registry, usage_fetcher: UsageFetchDetailedFn, results: []ForegroundUsageWorkerResult, - debug_context: ?ForegroundUsageDebugContext, ) void { for (reg.accounts.items, 0..) |_, idx| { - if (debug_context) |debug| { - printForegroundUsageDebugRequest(debug.logger, reg, idx, debug.label_state.labels[idx]) catch {}; - } - foregroundUsageRefreshWorker(allocator, codex_home, reg, idx, usage_fetcher, results, debug_context); + foregroundUsageRefreshWorker(allocator, codex_home, reg, idx, usage_fetcher, results); } } @@ -842,7 +697,6 @@ fn foregroundUsageRefreshWorker( account_idx: usize, usage_fetcher: UsageFetchDetailedFn, results: []ForegroundUsageWorkerResult, - debug_context: ?ForegroundUsageDebugContext, ) void { var arena_state = std.heap.ArenaAllocator.init(std.heap.smp_allocator); defer arena_state.deinit(); @@ -850,29 +704,11 @@ fn foregroundUsageRefreshWorker( const auth_path = registry.accountAuthPath(arena, codex_home, reg.accounts.items[account_idx].account_key) catch |err| { results[account_idx] = .{ .error_name = @errorName(err) }; - if (debug_context) |debug| { - printForegroundUsageDebugWorkerResult( - arena, - debug.logger, - debug.label_state.labels[account_idx], - reg.accounts.items[account_idx].last_usage, - results[account_idx], - ); - } return; }; const fetch_result = usage_fetcher(arena, auth_path) catch |err| { results[account_idx] = .{ .error_name = @errorName(err) }; - if (debug_context) |debug| { - printForegroundUsageDebugWorkerResult( - arena, - debug.logger, - debug.label_state.labels[account_idx], - reg.accounts.items[account_idx].last_usage, - results[account_idx], - ); - } return; }; @@ -888,29 +724,11 @@ fn foregroundUsageRefreshWorker( .missing_auth = fetch_result.missing_auth, .error_name = @errorName(err), }; - if (debug_context) |debug| { - printForegroundUsageDebugWorkerResult( - arena, - debug.logger, - debug.label_state.labels[account_idx], - reg.accounts.items[account_idx].last_usage, - results[account_idx], - ); - } return; }; } results[account_idx] = result; - if (debug_context) |debug| { - printForegroundUsageDebugWorkerResult( - arena, - debug.logger, - debug.label_state.labels[account_idx], - reg.accounts.items[account_idx].last_usage, - result, - ); - } } fn setForegroundUsageOverrideForOutcome( @@ -935,181 +753,6 @@ fn setForegroundUsageOverrideForOutcome( return false; } -fn buildDebugUsageLabelState( - allocator: std.mem.Allocator, - reg: *const registry.Registry, -) !DebugUsageLabelState { - var labels = try allocator.alloc([]const u8, reg.accounts.items.len); - errdefer allocator.free(labels); - for (reg.accounts.items, 0..) |rec, idx| { - labels[idx] = try allocator.dupe(u8, rec.email); - } - errdefer { - for (labels) |label| allocator.free(@constCast(label)); - } - - var display = try display_rows.buildDisplayRows(allocator, reg, null); - defer display.deinit(allocator); - for (display.rows) |row| { - const account_idx = row.account_index orelse continue; - const next_label = if (row.depth == 0) - try allocator.dupe(u8, row.account_cell) - else - try std.fmt.allocPrint(allocator, "{s} | {s}", .{ - reg.accounts.items[account_idx].email, - row.account_cell, - }); - allocator.free(@constCast(labels[account_idx])); - labels[account_idx] = next_label; - } - - return .{ - .labels = labels, - }; -} - -fn debugWorkerStatusLabel(buf: *[32]u8, result: ForegroundUsageWorkerResult) []const u8 { - if (result.error_name) |error_name| return error_name; - if (result.missing_auth) return "MissingAuth"; - if (result.status_code) |status_code| { - return std.fmt.bufPrint(buf, "{d}", .{status_code}) catch "-"; - } - return if (result.snapshot != null) "200" else "-"; -} - -fn workerResultHasNoUsageWindow(result: ForegroundUsageWorkerResult) bool { - return result.error_name == null and - !result.missing_auth and - result.snapshot == null and - result.status_code != null and - result.status_code.? == 200; -} - -fn formatRemainingPercentAlloc( - allocator: std.mem.Allocator, - window: ?registry.RateLimitWindow, -) ![]const u8 { - const remaining = registry.remainingPercentAt(window, std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds()) orelse return allocator.dupe(u8, "-"); - return std.fmt.allocPrint(allocator, "{d}%", .{remaining}); -} - -fn printForegroundUsageDebugStart( - logger: *ForegroundUsageDebugLogger, - account_count: usize, - node_executable: []const u8, -) !void { - try logger.print( - "[debug] usage refresh start: accounts={d} concurrency={d} timeout_ms={s} child_timeout_ms={s} endpoint={s} node={s}\n", - .{ - account_count, - @min(account_count, foreground_usage_refresh_concurrency), - chatgpt_http.request_timeout_ms, - chatgpt_http.child_process_timeout_ms, - usage_api.default_usage_endpoint, - node_executable, - }, - ); -} - -fn printForegroundUsageDebugDone(logger: *ForegroundUsageDebugLogger, state: *const ForegroundUsageRefreshState) !void { - try logger.print( - "[debug] usage refresh done: attempted={d} updated={d} failed={d} unchanged={d}\n", - .{ state.attempted, state.updated, state.failed, state.unchanged }, - ); -} - -fn printForegroundUsageDebugRequest( - logger: *ForegroundUsageDebugLogger, - reg: *const registry.Registry, - account_idx: usize, - label: []const u8, -) !void { - try logger.print( - "[debug] request usage: {s} account_id={s}\n", - .{ - label, - reg.accounts.items[account_idx].chatgpt_account_id, - }, - ); -} - -fn printForegroundUsageDebugWorkerResult( - allocator: std.mem.Allocator, - logger: *ForegroundUsageDebugLogger, - label: []const u8, - previous_snapshot: ?registry.RateLimitSnapshot, - result: ForegroundUsageWorkerResult, -) void { - var status_buf: [32]u8 = undefined; - if (workerResultHasNoUsageWindow(result)) { - logger.print( - "[debug] response usage: {s} status={s} result=no-usage-limits-window\n", - .{ - label, - debugWorkerStatusLabel(&status_buf, result), - }, - ) catch return; - } else if (result.snapshot != null) { - logger.print( - "[debug] response usage: {s} status={s} result=usage-windows\n", - .{ - label, - debugWorkerStatusLabel(&status_buf, result), - }, - ) catch return; - } else if (result.missing_auth) { - logger.print( - "[debug] response usage: {s} status={s} result=missing-auth\n", - .{ - label, - debugWorkerStatusLabel(&status_buf, result), - }, - ) catch return; - } else if (result.error_name != null) { - const result_kind = if (std.mem.eql(u8, result.error_name.?, "NodeProcessTimedOut")) - "node-process-timeout" - else if (std.mem.eql(u8, result.error_name.?, "NodeJsRequired")) - "node-launch-failed" - else - "error"; - logger.print( - "[debug] response usage: {s} status={s} result={s}\n", - .{ - label, - debugWorkerStatusLabel(&status_buf, result), - result_kind, - }, - ) catch return; - } else { - logger.print( - "[debug] response usage: {s} status={s} result=http-response\n", - .{ - label, - debugWorkerStatusLabel(&status_buf, result), - }, - ) catch return; - } - - const snapshot = result.snapshot orelse return; - if (registry.rateLimitSnapshotsEqual(previous_snapshot, snapshot)) return; - - const rate_5h = registry.resolveRateWindow(snapshot, 300, true); - const rate_weekly = registry.resolveRateWindow(snapshot, 10080, false); - const rate_5h_text = formatRemainingPercentAlloc(allocator, rate_5h) catch return; - defer allocator.free(rate_5h_text); - const rate_weekly_text = formatRemainingPercentAlloc(allocator, rate_weekly) catch return; - defer allocator.free(rate_weekly_text); - - logger.print( - "[debug] updated usage: {s} 5h={s} weekly={s}\n", - .{ - label, - rate_5h_text, - rate_weekly_text, - }, - ) catch {}; -} - pub fn maybeRefreshForegroundAccountNames( allocator: std.mem.Allocator, codex_home: []const u8, @@ -1608,18 +1251,10 @@ fn handleList(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.Li account_api_enabled, ); - var debug_stdout: io_util.Stdout = undefined; - var debug_logger: ?ForegroundUsageDebugLogger = null; - if (opts.debug) { - debug_stdout.init(); - debug_logger = ForegroundUsageDebugLogger.init(debug_stdout.out()); - } - - var usage_state = try refreshForegroundUsageForDisplayWithBatchFetcherAndDebugUsingApiEnabled( + var usage_state = try refreshForegroundUsageForDisplayWithBatchFetcherUsingApiEnabled( allocator, codex_home, ®, - if (debug_logger) |*logger| logger else null, usage_api_enabled, ); defer usage_state.deinit(allocator); @@ -2239,14 +1874,13 @@ fn loadSwitchSelectionDisplay( }, }; - var usage_state = refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitAndDebugUsingApiEnabledAndPersist( + var usage_state = refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitUsingApiEnabledAndPersist( allocator, codex_home, &refreshed, usage_api.fetchUsageForAuthPathDetailed, usage_api.fetchUsageForAuthPathsDetailedBatch, initForegroundUsagePool, - null, initial_policy.usage_api_enabled, false, false, @@ -2824,11 +2458,10 @@ fn handleRemove(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. }; } - usage_state = refreshForegroundUsageForDisplayWithBatchFetcherAndDebugUsingApiEnabledWithBatchFailurePolicy( + usage_state = refreshForegroundUsageForDisplayWithBatchFetcherUsingApiEnabledWithBatchFailurePolicy( allocator, codex_home, ®, - null, usage_api_enabled, false, ) catch |err| switch (err) { diff --git a/src/tests/cli_bdd_test.zig b/src/tests/cli_bdd_test.zig index b5e7655..dffdca4 100644 --- a/src/tests/cli_bdd_test.zig +++ b/src/tests/cli_bdd_test.zig @@ -166,21 +166,6 @@ test "Scenario: Given list with extra args when parsing then usage error is retu try expectUsageError(result, .list, "unexpected argument"); } -test "Scenario: Given list with debug flag when parsing then debug mode is preserved" { - const gpa = std.testing.allocator; - const args = [_][:0]const u8{ "codex-auth", "list", "--debug" }; - var result = try cli.parseArgs(gpa, &args); - defer cli.freeParseResult(gpa, &result); - - switch (result) { - .command => |cmd| switch (cmd) { - .list => |opts| try std.testing.expect(opts.debug), - else => return error.TestExpectedEqual, - }, - else => return error.TestExpectedEqual, - } -} - test "Scenario: Given list with skip-api flag when parsing then local-only display mode is preserved" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "list", "--skip-api" }; @@ -211,15 +196,6 @@ test "Scenario: Given list with live flag when parsing then live mode is preserv } } -test "Scenario: Given list with live and debug flags when parsing then usage error is returned" { - const gpa = std.testing.allocator; - const args = [_][:0]const u8{ "codex-auth", "list", "--live", "--debug" }; - var result = try cli.parseArgs(gpa, &args); - defer cli.freeParseResult(gpa, &result); - - try expectUsageError(result, .list, "`--debug` cannot be combined with `--live`"); -} - test "Scenario: Given list with api flag when parsing then forced api mode is preserved" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "list", "--api" }; @@ -333,7 +309,7 @@ test "Scenario: Given simple command help when rendering then examples are omitt const help = aw.written(); try std.testing.expect(std.mem.indexOf(u8, help, "codex-auth list") != null); try std.testing.expect(std.mem.indexOf(u8, help, "List available accounts.") != null); - try std.testing.expect(std.mem.indexOf(u8, help, "Usage:\n codex-auth list [--debug|--live] [--api|--skip-api]\n") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "Usage:\n codex-auth list [--live] [--api|--skip-api]\n") != null); try std.testing.expect(std.mem.indexOf(u8, help, "Examples:") == null); } diff --git a/src/tests/e2e_cli_test.zig b/src/tests/e2e_cli_test.zig index 76b21da..1e25ef2 100644 --- a/src/tests/e2e_cli_test.zig +++ b/src/tests/e2e_cli_test.zig @@ -1458,37 +1458,6 @@ test "Scenario: Given list with skip-api when running list then it does not requ try std.testing.expectEqualStrings("", result.stderr); } -test "Scenario: Given list with debug and no accounts when running list then it does not require api refresh executables" { - 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); - - try tmp.dir.makePath("empty-bin"); - const empty_path = try tmp.dir.realpathAlloc(gpa, "empty-bin"); - defer gpa.free(empty_path); - - const result = try runCliWithIsolatedHomeAndPath( - gpa, - project_root, - home_root, - empty_path, - &[_][]const u8{ "list", "--debug" }, - ); - defer gpa.free(result.stdout); - defer gpa.free(result.stderr); - - try expectSuccess(result); - try std.testing.expect(std.mem.indexOf(u8, result.stdout, "ACCOUNT") != null); - try std.testing.expectEqualStrings("", result.stderr); -} - test "Scenario: Given switch query with api flag when running switch then it returns a usage error" { const gpa = std.testing.allocator; const project_root = try projectRootAlloc(gpa); diff --git a/src/tests/main_test.zig b/src/tests/main_test.zig index f7a1fbe..c400fbb 100644 --- a/src/tests/main_test.zig +++ b/src/tests/main_test.zig @@ -627,138 +627,6 @@ test "Scenario: Given thread pool init failure when refreshing foreground usage try std.testing.expectEqual(@as(f64, 22), reg.accounts.items[0].last_usage.?.primary.?.used_percent); } -test "Scenario: Given debug usage refresh when listing then request and response details stream in refresh order" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - - const TestUsageFetcher = struct { - fn snapshot(plan: registry.PlanType, used_5h: f64, used_weekly: f64) registry.RateLimitSnapshot { - return .{ - .primary = .{ - .used_percent = used_5h, - .window_minutes = 300, - .resets_at = 4773491460, - }, - .secondary = .{ - .used_percent = used_weekly, - .window_minutes = 10080, - .resets_at = 4773749620, - }, - .credits = null, - .plan_type = plan, - }; - } - - fn fetch(allocator: std.mem.Allocator, auth_path: []const u8) !usage_api.UsageFetchResult { - var info = try auth_mod.parseAuthInfo(allocator, auth_path); - defer info.deinit(allocator); - - const account_id = info.chatgpt_account_id orelse return .{ - .snapshot = null, - .status_code = null, - .missing_auth = true, - }; - - if (std.mem.eql(u8, account_id, primary_account_id)) { - return .{ - .snapshot = snapshot(.team, 18, 39), - .status_code = 200, - }; - } - if (std.mem.eql(u8, account_id, secondary_account_id)) { - return .{ - .snapshot = null, - .status_code = 403, - }; - } - return .{ - .snapshot = null, - .status_code = 404, - }; - } - - fn failPoolInit( - allocator: std.mem.Allocator, - n_jobs: usize, - ) !void { - _ = allocator; - _ = n_jobs; - return error.ThreadQuotaExceeded; - } - }; - - var reg = makeRegistry(); - defer reg.deinit(gpa); - try appendAccount(gpa, ®, primary_record_key, "alpha@example.com", "", .team); - try appendAccount(gpa, ®, secondary_record_key, "beta@example.com", "", .team); - try registry.setActiveAccountKey(gpa, ®, primary_record_key); - - try writeAccountSnapshotWithIds(gpa, codex_home, "alpha@example.com", "team", shared_user_id, primary_account_id); - try writeAccountSnapshotWithIds(gpa, codex_home, "beta@example.com", "team", shared_user_id, secondary_account_id); - - var debug_output: std.Io.Writer.Allocating = .init(gpa); - defer debug_output.deinit(); - var debug_logger = main_mod.ForegroundUsageDebugLogger.init(&debug_output.writer); - - var state = try main_mod.refreshForegroundUsageForDisplayWithApiFetcherWithPoolInitAndDebug( - gpa, - codex_home, - ®, - TestUsageFetcher.fetch, - TestUsageFetcher.failPoolInit, - &debug_logger, - ); - defer state.deinit(gpa); - - const debug_text = debug_output.written(); - const start_idx = std.mem.indexOf( - u8, - debug_text, - "[debug] usage refresh start: accounts=2 concurrency=2 timeout_ms=5000 child_timeout_ms=7000 endpoint=https://chatgpt.com/backend-api/wham/usage node=", - ) orelse return error.TestExpectedEqual; - const request_primary_idx = std.mem.indexOf( - u8, - debug_text, - "[debug] request usage: alpha@example.com account_id=67fe2bbb-0de6-49a4-b2b3-d1df366d1faf\n", - ) orelse return error.TestExpectedEqual; - const response_primary_idx = std.mem.indexOf( - u8, - debug_text, - "[debug] response usage: alpha@example.com status=200 result=usage-windows\n", - ) orelse return error.TestExpectedEqual; - const updated_primary_idx = std.mem.indexOf( - u8, - debug_text, - "[debug] updated usage: alpha@example.com ", - ) orelse return error.TestExpectedEqual; - const request_secondary_idx = std.mem.indexOf( - u8, - debug_text, - "[debug] request usage: beta@example.com account_id=518a44d9-ba75-4bad-87e5-ae9377042960\n", - ) orelse return error.TestExpectedEqual; - const response_secondary_idx = std.mem.indexOf( - u8, - debug_text, - "[debug] response usage: beta@example.com status=403 result=http-response\n", - ) orelse return error.TestExpectedEqual; - const done_idx = std.mem.indexOf( - u8, - debug_text, - "[debug] usage refresh done: attempted=2 updated=1 failed=1 unchanged=0\n", - ) orelse return error.TestExpectedEqual; - - try std.testing.expect(start_idx < request_primary_idx); - try std.testing.expect(request_primary_idx < response_primary_idx); - try std.testing.expect(response_primary_idx < updated_primary_idx); - try std.testing.expect(updated_primary_idx < request_secondary_idx); - try std.testing.expect(request_secondary_idx < response_secondary_idx); - try std.testing.expect(response_secondary_idx < done_idx); -} - test "Scenario: Given list with missing team names when running foreground account-name refresh then it waits and saves the updated names" { const gpa = std.testing.allocator; var tmp = fs.tmpDir(.{});