diff --git a/docs/commands/list.md b/docs/commands/list.md index 14df4a7e..2db5056f 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -25,6 +25,7 @@ codex-auth list --skip-api - `--api` is accepted as an explicit equivalent to default mode. - `--skip-api` forbids remote API calls for this command. - `--live` keeps refreshing the terminal view and requires a TTY. +- In a TTY, plain `list` opens a sortable table without scheduled live refresh. Without a TTY, it prints a static table. When local-only refresh is active, only the active account can be updated from local rollout files. Non-active rows use the stored registry snapshot. @@ -34,4 +35,6 @@ When local-only refresh is active, only the active account can be updated from l - Usage cells show remaining percent and reset time when that data is known. - Remote refresh failures can render row overlays such as `401`, `403`, `TimedOut`, or `MissingAuth`. - `LAST ACTIVITY` is based on the last stored usage update time. +- In the interactive table, click a table header to sort by that column; click the same header again to reverse the direction. +- When the interactive table exits, it prints the final visible table, preserving the current sort order. - Shared table layout policy is documented in [docs/table-layout.md](../table-layout.md). diff --git a/docs/commands/remove.md b/docs/commands/remove.md index 2389de03..66ac75e0 100644 --- a/docs/commands/remove.md +++ b/docs/commands/remove.md @@ -16,6 +16,7 @@ codex-auth remove --all - The default picker stays local-only so deletion is not blocked by refresh work. - `--api` attempts a best-effort foreground refresh for picker display. - `--skip-api` explicitly forbids remote refresh. +- Click a table header to sort by that column; click the same header again to reverse the direction. - `q` quits without deleting accounts. ## Live Remove @@ -25,6 +26,7 @@ codex-auth remove --all - Removed rows disappear from the current display immediately. - Existing row overlays stay in place until the next scheduled refresh. - The active account shown after deletion comes from the persisted registry state. +- Click a table header to sort by that column; click the same header again to reverse the direction. ## Query Remove diff --git a/docs/commands/switch.md b/docs/commands/switch.md index cf2eb532..4842a70c 100644 --- a/docs/commands/switch.md +++ b/docs/commands/switch.md @@ -14,6 +14,7 @@ codex-auth switch - The picker uses the same account ordering as `list`. - `q` quits without switching. +- Click a table header to sort by that column; click the same header again to reverse the direction. - `--api` forces foreground remote refresh before rendering. - `--skip-api` renders from stored data and local-only active-account refresh where available. @@ -25,6 +26,7 @@ codex-auth switch - A successful switch patches the current display immediately. - In-flight refresh results are discarded after a manual switch. - Existing usage overlays stay visible until the next scheduled refresh. +- Click a table header to sort by that column; click the same header again to reverse the direction. ## Query Switch diff --git a/docs/table-layout.md b/docs/table-layout.md index 6169357b..2f8ff8cd 100644 --- a/docs/table-layout.md +++ b/docs/table-layout.md @@ -6,9 +6,8 @@ before expanding long labels. This applies to `switch`, `remove`, `list`, and `list --live` because they all render through the shared table code in `src/cli/table_layout.zig` and are -called by the renderers in `src/cli/render.zig`. The -width-priority rules only matter when a viewport width is known, which is -typically the live case. +called by the renderers in `src/cli/render.zig`. The width-priority rules only +matter when a viewport width is known, which is typically the live case. ## Column Width Priority diff --git a/src/cli/live.zig b/src/cli/live.zig index 2122a839..e72589cb 100644 --- a/src/cli/live.zig +++ b/src/cli/live.zig @@ -11,6 +11,7 @@ pub const SwitchLiveActionController = selection.SwitchLiveActionController; pub const RemoveLiveActionController = selection.RemoveLiveActionController; pub const selectAccountWithLiveUpdates = live_view.selectAccountWithLiveUpdates; +pub const viewAccountsWithSortableTable = live_view.viewAccountsWithSortableTable; pub const viewAccountsWithLiveUpdates = live_view.viewAccountsWithLiveUpdates; pub const runSwitchLiveActions = live_switch.runSwitchLiveActions; pub const runRemoveLiveActions = live_remove.runRemoveLiveActions; diff --git a/src/cli/live_remove.zig b/src/cli/live_remove.zig index ecae4f32..87ac0dd3 100644 --- a/src/cli/live_remove.zig +++ b/src/cli/live_remove.zig @@ -54,6 +54,7 @@ pub fn runRemoveLiveActions( var number_buf: [8]u8 = undefined; var number_len: usize = 0; + var sort_spec: ?row_data.SortSpec = null; var viewport_start: usize = 0; var follow_selection = true; var needs_render = true; @@ -82,7 +83,7 @@ pub fn runRemoveLiveActions( } if (needs_render or now_second != last_render_second) { const borrowed = current_display.borrowed(); - const rows = try rows_cache.ensure(allocator, borrowed); + const rows = try rows_cache.ensureSortable(allocator, borrowed, sort_spec); const cursor_idx = try live_tui.resolveSelectedIndex(allocator, &cursor_account_key, rows, borrowed.reg); try checked_flags_buf.resize(allocator, rows.selectable_row_indices.len); @@ -135,14 +136,14 @@ pub fn runRemoveLiveActions( .ready => |key_count| { if (key_count != 0) { const borrowed = current_display.borrowed(); - const rows = try rows_cache.ensure(allocator, borrowed); + const rows = try rows_cache.ensureSortable(allocator, borrowed, sort_spec); const page_rows = live_tui.maxTableRows( tui.terminalRows(), live_tui.switchFixedLines("status", action_message orelse ""), ); const wheel_rows = live_tui.mouseWheelRows(page_rows); - for (key_buf[0..key_count]) |key| { + key_loop: for (key_buf[0..key_count]) |key| { const cursor_idx = try live_tui.resolveSelectedIndex(allocator, &cursor_account_key, rows, borrowed.reg); switch (key) { .move_up => { @@ -248,6 +249,17 @@ pub fn runRemoveLiveActions( } }, .redraw => needs_render = true, + .mouse_click => |click| { + const idx_width = @max(@as(usize, 2), indexWidth(rows.selectable_row_indices.len)); + if (live_tui.removeHeaderSortFieldForClick(rows, idx_width, 2, tui.terminalCols(), click)) |field| { + sort_spec = live_tui.toggledSortSpec(sort_spec, field); + viewport_start = 0; + follow_selection = true; + rows_cache.invalidate(allocator); + needs_render = true; + break :key_loop; + } + }, .byte => |ch| { if (isQuitKey(ch)) return; if (ch == 'k') { diff --git a/src/cli/live_switch.zig b/src/cli/live_switch.zig index be66c187..a67a23cc 100644 --- a/src/cli/live_switch.zig +++ b/src/cli/live_switch.zig @@ -53,6 +53,7 @@ pub fn runSwitchLiveActions( var number_buf: [8]u8 = undefined; var number_len: usize = 0; var auto_switch_state = live_tui.LiveAutoSwitchState.init(controller.auto_switch); + var sort_spec: ?row_data.SortSpec = null; var viewport_start: usize = 0; var follow_selection = true; var needs_render = true; @@ -93,7 +94,7 @@ pub fn runSwitchLiveActions( if (auto_switch_state.takePending()) { const borrowed = current_display.borrowed(); - const rows = try rows_cache.ensureSelectable(allocator, borrowed); + const rows = try rows_cache.ensureSortable(allocator, borrowed, sort_spec); if (try maybeAutoSwitchTargetKeyAlloc(allocator, borrowed, rows)) |target_key| { defer allocator.free(target_key); const outcome = controller.apply_selection(controller.refresh.context, allocator, borrowed, target_key) catch |err| { @@ -122,7 +123,7 @@ pub fn runSwitchLiveActions( if (needs_render or now_second != last_render_second) { const borrowed = current_display.borrowed(); - const rows = try rows_cache.ensureSelectable(allocator, borrowed); + const rows = try rows_cache.ensureSortable(allocator, borrowed, sort_spec); const total_accounts = accountRowCount(rows.items); const selected_idx = try live_tui.resolveSelectedIndex(allocator, &selected_account_key, rows, borrowed.reg); const status_line = try controller.refresh.build_status_line(controller.refresh.context, allocator, borrowed); @@ -168,7 +169,7 @@ pub fn runSwitchLiveActions( .ready => |key_count| { if (key_count != 0) { const borrowed = current_display.borrowed(); - const rows = try rows_cache.ensureSelectable(allocator, borrowed); + const rows = try rows_cache.ensureSortable(allocator, borrowed, sort_spec); const total_accounts = accountRowCount(rows.items); const page_rows = live_tui.maxTableRows( tui.terminalRows(), @@ -176,7 +177,7 @@ pub fn runSwitchLiveActions( ); const wheel_rows = live_tui.mouseWheelRows(page_rows); - for (key_buf[0..key_count]) |key| { + key_loop: for (key_buf[0..key_count]) |key| { const selected_idx = try live_tui.resolveSelectedIndex(allocator, &selected_account_key, rows, borrowed.reg); switch (key) { .move_up => { @@ -284,6 +285,17 @@ pub fn runSwitchLiveActions( } }, .redraw => needs_render = true, + .mouse_click => |click| { + const idx_width = @max(@as(usize, 2), indexWidth(total_accounts)); + if (live_tui.switchHeaderSortFieldForClick(rows, idx_width, 2, tui.terminalCols(), click)) |field| { + sort_spec = live_tui.toggledSortSpec(sort_spec, field); + viewport_start = 0; + follow_selection = true; + rows_cache.invalidate(allocator); + needs_render = true; + break :key_loop; + } + }, .byte => |ch| { if (isQuitKey(ch)) return; if (ch == 'k') { diff --git a/src/cli/live_tui.zig b/src/cli/live_tui.zig index 5409704b..159819b1 100644 --- a/src/cli/live_tui.zig +++ b/src/cli/live_tui.zig @@ -5,6 +5,7 @@ const picker = @import("picker.zig"); const render = @import("render.zig"); const row_data = @import("rows.zig"); const selection = @import("selection.zig"); +const table_layout = @import("table_layout.zig"); const tui_mod = @import("tui.zig"); pub const tick_ms = tui_mod.live_ui_tick_ms; @@ -235,6 +236,82 @@ pub fn applyListViewportKey( } } +pub fn toggledSortSpec(current: ?row_data.SortSpec, field: row_data.SortField) row_data.SortSpec { + if (current) |spec| { + if (spec.field == field) { + return .{ + .field = field, + .direction = if (spec.direction == .asc) .desc else .asc, + }; + } + } + return .{ .field = field, .direction = .asc }; +} + +pub fn listHeaderSortFieldForClick( + rows: *const row_data.SwitchRows, + idx_width: usize, + max_cols: ?usize, + click: tui_mod.TuiMouseClick, +) ?row_data.SortField { + return tableHeaderSortFieldForClick(rows, 2 + idx_width + 1, 1, max_cols, click); +} + +pub fn switchHeaderSortFieldForClick( + rows: *const row_data.SwitchRows, + idx_width: usize, + header_row: usize, + max_cols: ?usize, + click: tui_mod.TuiMouseClick, +) ?row_data.SortField { + return tableHeaderSortFieldForClick(rows, 2 + idx_width + 1, header_row, max_cols, click); +} + +pub fn removeHeaderSortFieldForClick( + rows: *const row_data.SwitchRows, + idx_width: usize, + header_row: usize, + max_cols: ?usize, + click: tui_mod.TuiMouseClick, +) ?row_data.SortField { + return tableHeaderSortFieldForClick(rows, 2 + 3 + 1 + idx_width + 1, header_row, max_cols, click); +} + +pub fn tableHeaderSortFieldForClick( + rows: *const row_data.SwitchRows, + prefix_width: usize, + header_row: usize, + max_cols: ?usize, + click: tui_mod.TuiMouseClick, +) ?row_data.SortField { + if (click.row != header_row) return null; + + const bounded = table_layout.boundWidths(rows.widths, prefix_width, max_cols); + const widths = [_]usize{ + bounded.email, + bounded.plan, + bounded.rate_5h, + bounded.rate_week, + bounded.last, + }; + const fields = [_]row_data.SortField{ + .account, + .plan, + .five_hour, + .weekly, + .last_activity, + }; + + var start_col = prefix_width + 1; + for (widths, 0..) |width, idx| { + if (width != 0 and click.col >= start_col and click.col < start_col + width) { + return fields[idx]; + } + start_col += width + 2; + } + return null; +} + pub fn buildSelectableRows( allocator: std.mem.Allocator, display: selection.SwitchSelectionDisplay, @@ -269,6 +346,17 @@ pub const RowsCache = struct { return &self.rows.?; } + pub fn ensureList( + self: *RowsCache, + allocator: std.mem.Allocator, + display: selection.SwitchSelectionDisplay, + sort_spec: ?row_data.SortSpec, + ) !*row_data.SwitchRows { + if (self.rows) |*rows| return rows; + self.rows = try row_data.buildListRowsWithUsageOverrides(allocator, display.reg, display.usage_overrides, sort_spec); + return &self.rows.?; + } + pub fn ensureSelectable( self: *RowsCache, allocator: std.mem.Allocator, @@ -280,6 +368,20 @@ pub const RowsCache = struct { self.rows = rows; return &self.rows.?; } + + pub fn ensureSortable( + self: *RowsCache, + allocator: std.mem.Allocator, + display: selection.SwitchSelectionDisplay, + sort_spec: ?row_data.SortSpec, + ) !*row_data.SwitchRows { + if (self.rows) |*rows| return rows; + var rows = try row_data.buildSortableRowsWithUsageOverrides(allocator, display.reg, null, display.usage_overrides, sort_spec); + errdefer rows.deinit(allocator); + try row_data.filterErroredRowsFromSelectableIndices(allocator, &rows); + self.rows = rows; + return &self.rows.?; + } }; pub fn resolveSelectedIndex( diff --git a/src/cli/live_view.zig b/src/cli/live_view.zig index 6377d59b..14756fbd 100644 --- a/src/cli/live_view.zig +++ b/src/cli/live_view.zig @@ -21,6 +21,7 @@ const mapTuiOutputError = tui_mod.mapTuiOutputError; const indexWidth = row_data.indexWidth; const renderSwitchScreenViewport = render.renderSwitchScreenViewport; const renderListScreenViewport = render.renderListScreenViewport; +const renderSwitchListViewport = render.renderSwitchListViewport; const shouldUseNumberedSwitchSelector = picker.shouldUseNumberedSwitchSelector; const selectWithNumbers = picker.selectWithNumbers; const dupeOptionalAccountKey = picker.dupeOptionalAccountKey; @@ -63,6 +64,7 @@ pub fn selectAccountWithLiveUpdates( var number_buf: [8]u8 = undefined; var number_len: usize = 0; + var sort_spec: ?row_data.SortSpec = null; var viewport_start: usize = 0; var needs_render = true; var last_render_second: i64 = -1; @@ -88,7 +90,7 @@ pub fn selectAccountWithLiveUpdates( } if (needs_render or now_second != last_render_second) { const borrowed = current_display.borrowed(); - const rows = try rows_cache.ensureSelectable(allocator, borrowed); + const rows = try rows_cache.ensureSortable(allocator, borrowed, sort_spec); const total_accounts = accountRowCount(rows.items); if (total_accounts == 0) return null; @@ -135,7 +137,7 @@ pub fn selectAccountWithLiveUpdates( .ready => |key_count| { if (key_count != 0) { const borrowed = current_display.borrowed(); - const rows = try rows_cache.ensureSelectable(allocator, borrowed); + const rows = try rows_cache.ensureSortable(allocator, borrowed, sort_spec); const total_accounts = accountRowCount(rows.items); if (total_accounts == 0) return null; const page_rows = live_tui.maxTableRows( @@ -144,7 +146,7 @@ pub fn selectAccountWithLiveUpdates( ); const wheel_rows = live_tui.mouseWheelRows(page_rows); - for (key_buf[0..key_count]) |key| { + key_loop: for (key_buf[0..key_count]) |key| { const selected_idx = try live_tui.resolveSelectedIndex(allocator, &selected_account_key, rows, borrowed.reg); switch (key) { .move_up, .keyboard_up => { @@ -201,6 +203,16 @@ pub fn selectAccountWithLiveUpdates( } }, .redraw => needs_render = true, + .mouse_click => |click| { + const idx_width = @max(@as(usize, 2), indexWidth(total_accounts)); + if (live_tui.switchHeaderSortFieldForClick(rows, idx_width, 2, tui.terminalCols(), click)) |field| { + sort_spec = live_tui.toggledSortSpec(sort_spec, field); + viewport_start = 0; + rows_cache.invalidate(allocator); + needs_render = true; + break :key_loop; + } + }, .byte => |ch| { if (isQuitKey(ch)) return null; if (ch == 'k') { @@ -244,11 +256,14 @@ pub fn viewAccountsWithLiveUpdates( var tui: TuiSession = undefined; try tui.init(); - defer tui.deinit(); + var tui_deinitialized = false; + errdefer if (!tui_deinitialized) tui.deinit(); const use_color = terminal_color.fileColorEnabled(tui.output); var viewport_start: usize = 0; var rendered_row_count: usize = current_display.reg.accounts.items.len; + var last_viewport: render.LiveListViewport = .{}; + var sort_spec: ?row_data.SortSpec = null; var needs_render = true; var last_render_second: i64 = -1; var last_rows_minute: i64 = -1; @@ -257,7 +272,7 @@ pub fn viewAccountsWithLiveUpdates( var frame: std.Io.Writer.Allocating = .init(allocator); defer frame.deinit(); - while (true) { + main_loop: while (true) { if (try controller.maybe_take_updated_display(controller.context)) |updated| { current_display.deinit(allocator); current_display = updated; @@ -272,7 +287,7 @@ pub fn viewAccountsWithLiveUpdates( last_rows_minute = now_minute; } if (needs_render or now_second != last_render_second) { - const rows = try rows_cache.ensure(allocator, current_display.borrowed()); + const rows = try rows_cache.ensureList(allocator, current_display.borrowed(), sort_spec); rendered_row_count = rows.items.len; const status_line = try controller.build_status_line(controller.context, allocator, current_display.borrowed()); defer allocator.free(status_line); @@ -297,6 +312,7 @@ pub fn viewAccountsWithLiveUpdates( bounded_viewport, ) catch |err| return mapTuiOutputError(err); try tui.drawFrame(frame.written()); + last_viewport = bounded_viewport; last_render_second = now_second; needs_render = false; } @@ -307,24 +323,34 @@ pub fn viewAccountsWithLiveUpdates( try controller.maybe_start_refresh(controller.context); continue; }, - .closed => return, + .closed => break :main_loop, .ready => |key_count| { if (key_count != 0) { const max_rows = live_tui.maxTableRows(tui.terminalRows(), live_tui.listFixedLines("status")); const wheel_rows = live_tui.mouseWheelRows(max_rows); - const rows = try rows_cache.ensure(allocator, current_display.borrowed()); + const rows = try rows_cache.ensureList(allocator, current_display.borrowed(), sort_spec); rendered_row_count = rows.items.len; - for (key_buf[0..key_count]) |key| { + key_loop: for (key_buf[0..key_count]) |key| { if (live_tui.applyListViewportKey(rendered_row_count, max_rows, &viewport_start, wheel_rows, key)) { needs_render = true; continue; } switch (key) { - .quit => return, + .quit => break :main_loop, .redraw => needs_render = true, + .mouse_click => |click| { + const idx_width = @max(@as(usize, 2), indexWidth(rows.selectable_row_indices.len)); + if (live_tui.listHeaderSortFieldForClick(rows, idx_width, tui.terminalCols(), click)) |field| { + sort_spec = live_tui.toggledSortSpec(sort_spec, field); + viewport_start = 0; + rows_cache.invalidate(allocator); + needs_render = true; + break :key_loop; + } + }, .byte => |ch| { - if (isQuitKey(ch)) return; + if (isQuitKey(ch)) break :main_loop; }, else => {}, } @@ -333,4 +359,154 @@ pub fn viewAccountsWithLiveUpdates( }, } } + + const final_table = try buildListExitTableSnapshot( + allocator, + current_display.borrowed(), + &rows_cache, + sort_spec, + use_color, + last_viewport, + ); + defer allocator.free(final_table); + + tui.deinit(); + tui_deinitialized = true; + try writeListExitTable(final_table); +} + +pub fn viewAccountsWithSortableTable( + allocator: std.mem.Allocator, + display: SwitchSelectionDisplay, +) !void { + var tui: TuiSession = undefined; + try tui.init(); + var tui_deinitialized = false; + errdefer if (!tui_deinitialized) tui.deinit(); + + const use_color = terminal_color.fileColorEnabled(tui.output); + var viewport_start: usize = 0; + var rendered_row_count: usize = display.reg.accounts.items.len; + var last_viewport: render.LiveListViewport = .{}; + var sort_spec: ?row_data.SortSpec = null; + var needs_render = true; + var rows_cache: live_tui.RowsCache = .{}; + defer rows_cache.deinit(allocator); + var frame: std.Io.Writer.Allocating = .init(allocator); + defer frame.deinit(); + + main_loop: while (true) { + if (needs_render) { + const rows = try rows_cache.ensureList(allocator, display, sort_spec); + rendered_row_count = rows.items.len; + const viewport = live_tui.listViewport( + tui.terminalRows(), + rows.items.len, + live_tui.listFixedLines(""), + &viewport_start, + ); + var bounded_viewport = viewport; + bounded_viewport.max_cols = tui.terminalCols(); + + frame.clearRetainingCapacity(); + renderListScreenViewport( + &frame.writer, + display.reg, + rows.items, + @max(@as(usize, 2), indexWidth(rows.selectable_row_indices.len)), + rows.widths, + use_color, + "", + bounded_viewport, + ) catch |err| return mapTuiOutputError(err); + try tui.drawFrame(frame.written()); + last_viewport = bounded_viewport; + needs_render = false; + } + + var key_buf: [live_tui.key_buffer_len]tui_mod.TuiInputKey = undefined; + switch (try tui.readInputKeys(-1, &key_buf)) { + .timeout => continue, + .closed => break :main_loop, + .ready => |key_count| { + if (key_count != 0) { + const max_rows = live_tui.maxTableRows(tui.terminalRows(), live_tui.listFixedLines("")); + const wheel_rows = live_tui.mouseWheelRows(max_rows); + const rows = try rows_cache.ensureList(allocator, display, sort_spec); + rendered_row_count = rows.items.len; + + key_loop: for (key_buf[0..key_count]) |key| { + if (live_tui.applyListViewportKey(rendered_row_count, max_rows, &viewport_start, wheel_rows, key)) { + needs_render = true; + continue; + } + switch (key) { + .quit => break :main_loop, + .redraw => needs_render = true, + .mouse_click => |click| { + const idx_width = @max(@as(usize, 2), indexWidth(rows.selectable_row_indices.len)); + if (live_tui.listHeaderSortFieldForClick(rows, idx_width, tui.terminalCols(), click)) |field| { + sort_spec = live_tui.toggledSortSpec(sort_spec, field); + viewport_start = 0; + rows_cache.invalidate(allocator); + needs_render = true; + break :key_loop; + } + }, + .byte => |ch| { + if (isQuitKey(ch)) break :main_loop; + }, + else => {}, + } + } + } + }, + } + } + + const final_table = try buildListExitTableSnapshot( + allocator, + display, + &rows_cache, + sort_spec, + use_color, + last_viewport, + ); + defer allocator.free(final_table); + + tui.deinit(); + tui_deinitialized = true; + try writeListExitTable(final_table); +} + +fn buildListExitTableSnapshot( + allocator: std.mem.Allocator, + display: SwitchSelectionDisplay, + rows_cache: *live_tui.RowsCache, + sort_spec: ?row_data.SortSpec, + use_color: bool, + viewport: render.LiveListViewport, +) ![]u8 { + const rows = try rows_cache.ensureList(allocator, display, sort_spec); + var output: std.Io.Writer.Allocating = .init(allocator); + errdefer output.deinit(); + renderSwitchListViewport( + &output.writer, + display.reg, + rows.items, + @max(@as(usize, 2), indexWidth(rows.selectable_row_indices.len)), + rows.widths, + null, + use_color, + viewport, + ) catch |err| return mapTuiOutputError(err); + return try output.toOwnedSlice(); +} + +fn writeListExitTable(table: []const u8) !void { + var buffer: [4096]u8 = undefined; + var stdout = std.Io.File.stdout().writer(app_runtime.io(), &buffer); + const out = &stdout.interface; + try out.writeAll(table); + try out.flush(); } diff --git a/src/cli/picker_remove.zig b/src/cli/picker_remove.zig index ff71ee00..f0cabc31 100644 --- a/src/cli/picker_remove.zig +++ b/src/cli/picker_remove.zig @@ -7,6 +7,7 @@ const terminal_color = @import("../terminal/color.zig"); const row_data = @import("rows.zig"); const render = @import("render.zig"); const tui_mod = @import("tui.zig"); +const live_tui = @import("live_tui.zig"); const style = @import("style.zig"); const io = @import("io.zig"); const nav = @import("picker_nav.zig"); @@ -23,6 +24,8 @@ const writeRemoveTuiFooter = tui_mod.writeRemoveTuiFooter; const mapTuiOutputError = tui_mod.mapTuiOutputError; const readFileOnce = io.readFileOnce; const accountIndexForSelectable = nav.accountIndexForSelectable; +const accountIdForSelectable = nav.accountIdForSelectable; +const selectableIndexForAccountKey = nav.selectableIndexForAccountKey; const isQuitKey = nav.isQuitKey; pub fn shouldUseNumberedRemoveSelector(is_windows: bool, stdin_is_tty: bool, stdout_is_tty: bool) bool { @@ -65,14 +68,13 @@ fn selectRemoveWithNumbers( defer rows.deinit(allocator); const use_color = style.stdoutColorEnabled(); const idx_width = @max(@as(usize, 2), indexWidth(rows.selectable_row_indices.len)); - const widths = rows.widths; var checked = try allocator.alloc(bool, rows.selectable_row_indices.len); defer allocator.free(checked); @memset(checked, false); try out.writeAll("Select accounts to delete:\n\n"); - try renderRemoveList(out, reg, rows.items, idx_width, widths, null, checked, use_color); + try renderRemoveList(out, reg, rows.items, idx_width, rows.widths, null, checked, use_color); try out.writeAll("Enter account numbers (comma/space separated, empty to cancel): "); try out.flush(); @@ -147,13 +149,13 @@ fn selectRemoveInteractive( var number_len: usize = 0; const use_color = terminal_color.fileColorEnabled(tui.output); const idx_width = @max(@as(usize, 2), indexWidth(rows.selectable_row_indices.len)); - const widths = rows.widths; + var sort_spec: ?row_data.SortSpec = null; while (true) { try tui.resetFrame(); writeTuiPromptLine(out, "Select accounts to delete:", number_buf[0..number_len]) catch |err| return mapTuiOutputError(err); out.writeAll("\n") catch |err| return mapTuiOutputError(err); - renderRemoveList(out, reg, rows.items, idx_width, widths, idx, checked, use_color) catch |err| return mapTuiOutputError(err); + renderRemoveList(out, reg, rows.items, idx_width, rows.widths, idx, checked, use_color) catch |err| return mapTuiOutputError(err); out.writeAll("\n") catch |err| return mapTuiOutputError(err); writeRemoveTuiFooter(out, use_color) catch |err| return mapTuiOutputError(err); try tui.flushOutput(); @@ -210,6 +212,7 @@ fn selectRemoveInteractive( } }, .redraw => continue, + .mouse_click => {}, .byte => |ch| { if (isQuitKey(ch)) return null; if (ch == 'k' and idx > 0) { @@ -265,6 +268,23 @@ fn selectRemoveInteractive( } }, .quit => return null, + .mouse_click => { + if (escape.mouse_click) |click| { + if (live_tui.removeHeaderSortFieldForClick(&rows, idx_width, 3, tui.terminalCols(), click)) |field| { + try rebuildInteractiveRemoveRowsForSort( + allocator, + reg, + usage_overrides, + &rows, + &checked, + &idx, + &sort_spec, + field, + ); + number_len = 0; + } + } + }, .keyboard_enhancement_supported, .ignore => {}, } i += escape.buffered_bytes_consumed; @@ -328,3 +348,51 @@ fn selectRemoveInteractive( } } } + +fn rebuildInteractiveRemoveRowsForSort( + allocator: std.mem.Allocator, + reg: *registry.Registry, + usage_overrides: ?[]const ?[]const u8, + rows: *row_data.SwitchRows, + checked: *[]bool, + idx: *usize, + sort_spec: *?row_data.SortSpec, + field: row_data.SortField, +) !void { + const selected_key = if (rows.selectable_row_indices.len != 0) + try allocator.dupe(u8, accountIdForSelectable(rows, reg, idx.*)) + else + null; + defer if (selected_key) |key| allocator.free(key); + + const checked_by_account = try allocator.alloc(bool, reg.accounts.items.len); + defer allocator.free(checked_by_account); + @memset(checked_by_account, false); + for (checked.*, 0..) |flag, selectable_idx| { + if (flag) checked_by_account[accountIndexForSelectable(rows, selectable_idx)] = true; + } + + sort_spec.* = live_tui.toggledSortSpec(sort_spec.*, field); + var next_rows = try row_data.buildSortableRowsWithUsageOverrides(allocator, reg, null, usage_overrides, sort_spec.*); + errdefer next_rows.deinit(allocator); + + const next_checked = try allocator.alloc(bool, next_rows.selectable_row_indices.len); + errdefer allocator.free(next_checked); + for (next_checked, 0..) |*flag, selectable_idx| { + flag.* = checked_by_account[accountIndexForSelectable(&next_rows, selectable_idx)]; + } + + rows.deinit(allocator); + rows.* = next_rows; + allocator.free(checked.*); + checked.* = next_checked; + + if (selected_key) |key| { + idx.* = selectableIndexForAccountKey(rows, reg, key) orelse 0; + } else { + idx.* = 0; + } + if (rows.selectable_row_indices.len != 0 and idx.* >= rows.selectable_row_indices.len) { + idx.* = rows.selectable_row_indices.len - 1; + } +} diff --git a/src/cli/picker_switch.zig b/src/cli/picker_switch.zig index 24bd321b..57b07738 100644 --- a/src/cli/picker_switch.zig +++ b/src/cli/picker_switch.zig @@ -7,6 +7,7 @@ const terminal_color = @import("../terminal/color.zig"); const row_data = @import("rows.zig"); const render = @import("render.zig"); const tui_mod = @import("tui.zig"); +const live_tui = @import("live_tui.zig"); const style = @import("style.zig"); const io = @import("io.zig"); const nav = @import("picker_nav.zig"); @@ -28,6 +29,7 @@ const accountIdForSelectable = nav.accountIdForSelectable; const accountRowCount = nav.accountRowCount; const displayedIndexForSelectable = nav.displayedIndexForSelectable; const selectableIndexForDisplayedAccount = nav.selectableIndexForDisplayedAccount; +const selectableIndexForAccountKey = nav.selectableIndexForAccountKey; const accountIdForDisplayedAccount = nav.accountIdForDisplayedAccount; const parsedDisplayedIndex = nav.parsedDisplayedIndex; const selectedDisplayIndexForRender = nav.selectedDisplayIndexForRender; @@ -185,7 +187,7 @@ fn selectInteractiveFromIndices( var number_len: usize = 0; const use_color = terminal_color.fileColorEnabled(tui.output); const idx_width = @max(@as(usize, 2), indexWidth(total_accounts)); - const widths = rows.widths; + var sort_spec: ?row_data.SortSpec = null; while (true) { const selected_display_idx = selectedDisplayIndexForRender( @@ -199,7 +201,7 @@ fn selectInteractiveFromIndices( reg, rows.items, idx_width, - widths, + rows.widths, selected_display_idx, use_color, "", @@ -253,6 +255,7 @@ fn selectInteractiveFromIndices( } }, .redraw => continue, + .mouse_click => {}, .byte => |ch| { if (isQuitKey(ch)) return null; if (ch == 'k' and rows.selectable_row_indices.len != 0 and idx > 0) { @@ -316,6 +319,23 @@ fn selectInteractiveFromIndices( } }, .quit => return null, + .mouse_click => { + if (escape.mouse_click) |click| { + if (live_tui.switchHeaderSortFieldForClick(&rows, idx_width, 2, tui.terminalCols(), click)) |field| { + try rebuildInteractiveSwitchRowsForSort( + allocator, + reg, + indices, + usage_overrides, + &rows, + &idx, + &sort_spec, + field, + ); + number_len = 0; + } + } + }, .keyboard_enhancement_supported, .ignore => {}, } i += escape.buffered_bytes_consumed; @@ -390,7 +410,7 @@ fn selectInteractive( var number_len: usize = 0; const use_color = terminal_color.fileColorEnabled(tui.output); const idx_width = @max(@as(usize, 2), indexWidth(total_accounts)); - const widths = rows.widths; + var sort_spec: ?row_data.SortSpec = null; while (true) { const selected_display_idx = selectedDisplayIndexForRender( @@ -404,7 +424,7 @@ fn selectInteractive( reg, rows.items, idx_width, - widths, + rows.widths, selected_display_idx, use_color, "", @@ -458,6 +478,7 @@ fn selectInteractive( } }, .redraw => continue, + .mouse_click => {}, .byte => |ch| { if (isQuitKey(ch)) return null; if (ch == 'k' and rows.selectable_row_indices.len != 0 and idx > 0) { @@ -521,6 +542,23 @@ fn selectInteractive( } }, .quit => return null, + .mouse_click => { + if (escape.mouse_click) |click| { + if (live_tui.switchHeaderSortFieldForClick(&rows, idx_width, 2, tui.terminalCols(), click)) |field| { + try rebuildInteractiveSwitchRowsForSort( + allocator, + reg, + null, + usage_overrides, + &rows, + &idx, + &sort_spec, + field, + ); + number_len = 0; + } + } + }, .keyboard_enhancement_supported, .ignore => {}, } i += escape.buffered_bytes_consumed; @@ -571,3 +609,37 @@ fn selectInteractive( } } } + +fn rebuildInteractiveSwitchRowsForSort( + allocator: std.mem.Allocator, + reg: *registry.Registry, + indices: ?[]const usize, + usage_overrides: ?[]const ?[]const u8, + rows: *row_data.SwitchRows, + idx: *usize, + sort_spec: *?row_data.SortSpec, + field: row_data.SortField, +) !void { + const selected_key = if (rows.selectable_row_indices.len != 0) + try allocator.dupe(u8, accountIdForSelectable(rows, reg, idx.*)) + else + null; + defer if (selected_key) |key| allocator.free(key); + + sort_spec.* = live_tui.toggledSortSpec(sort_spec.*, field); + var next_rows = try row_data.buildSortableRowsWithUsageOverrides(allocator, reg, indices, usage_overrides, sort_spec.*); + errdefer next_rows.deinit(allocator); + try filterErroredRowsFromSelectableIndices(allocator, &next_rows); + + rows.deinit(allocator); + rows.* = next_rows; + + if (selected_key) |key| { + idx.* = selectableIndexForAccountKey(rows, reg, key) orelse 0; + } else { + idx.* = 0; + } + if (rows.selectable_row_indices.len != 0 and idx.* >= rows.selectable_row_indices.len) { + idx.* = rows.selectable_row_indices.len - 1; + } +} diff --git a/src/cli/rows.zig b/src/cli/rows.zig index 0e1d2321..8bf949ba 100644 --- a/src/cli/rows.zig +++ b/src/cli/rows.zig @@ -48,6 +48,24 @@ pub const SwitchRows = struct { } }; +pub const SortField = enum { + account, + plan, + five_hour, + weekly, + last_activity, +}; + +pub const SortDirection = enum { + asc, + desc, +}; + +pub const SortSpec = struct { + field: SortField, + direction: SortDirection, +}; + pub fn filterErroredRowsFromSelectableIndices(allocator: std.mem.Allocator, rows: *SwitchRows) !void { var selectable_count: usize = 0; for (rows.selectable_row_indices) |row_idx| { @@ -107,7 +125,7 @@ pub fn buildSwitchRowsWithUsageOverrides( for (display.rows, 0..) |display_row, i| { if (display_row.account_index) |account_idx| { const rec = reg.accounts.items[account_idx]; - const plan = if (registry.resolveDisplayPlan(&rec)) |p| registry.planLabel(p) else "-"; + const plan = displayPlan(&rec); const rate_5h = resolveRateWindow(rec.last_usage, 300, true); const rate_week = resolveRateWindow(rec.last_usage, 10080, false); const usage_override = usageOverrideForAccount(usage_overrides, account_idx); @@ -155,6 +173,108 @@ pub fn buildSwitchRowsWithUsageOverrides( }; } +pub fn buildListRowsWithUsageOverrides( + allocator: std.mem.Allocator, + reg: *registry.Registry, + usage_overrides: ?[]const ?[]const u8, + sort_spec: ?SortSpec, +) !SwitchRows { + return buildSortableRowsWithUsageOverrides(allocator, reg, null, usage_overrides, sort_spec); +} + +pub fn buildSortableRowsWithUsageOverrides( + allocator: std.mem.Allocator, + reg: *registry.Registry, + maybe_indices: ?[]const usize, + usage_overrides: ?[]const ?[]const u8, + sort_spec: ?SortSpec, +) !SwitchRows { + const spec = sort_spec orelse { + if (maybe_indices) |indices| { + return buildSwitchRowsFromIndicesWithUsageOverrides(allocator, reg, indices, usage_overrides); + } + return buildSwitchRowsWithUsageOverrides(allocator, reg, usage_overrides); + }; + + const source_len = if (maybe_indices) |indices| indices.len else reg.accounts.items.len; + const indices = try allocator.alloc(usize, source_len); + defer allocator.free(indices); + if (maybe_indices) |source_indices| { + @memcpy(indices, source_indices); + } else { + for (indices, 0..) |*slot, idx| slot.* = idx; + } + + const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); + std.sort.insertion(usize, indices, SortContext{ + .reg = reg, + .usage_overrides = usage_overrides, + .spec = spec, + .now = now, + }, sortedAccountLessThan); + + var rows = try allocator.alloc(SwitchRow, indices.len); + var initialized_rows: usize = 0; + errdefer { + for (rows[0..initialized_rows]) |*row| row.deinit(allocator); + allocator.free(rows); + } + + var selectable = try allocator.alloc(usize, indices.len); + errdefer allocator.free(selectable); + + var widths = SwitchWidths{ + .email = "EMAIL".len, + .plan = "PLAN".len, + .rate_5h = "5H".len, + .rate_week = "WEEKLY".len, + .last = "LAST".len, + }; + + for (indices, 0..) |account_idx, i| { + const rec = reg.accounts.items[account_idx]; + const plan = displayPlan(&rec); + const rate_5h = resolveRateWindow(rec.last_usage, 300, true); + const rate_week = resolveRateWindow(rec.last_usage, 10080, false); + const usage_override = usageOverrideForAccount(usage_overrides, account_idx); + const rate_5h_str = try usageCellTextAlloc(allocator, rate_5h, usage_override); + errdefer allocator.free(rate_5h_str); + const rate_week_str = try usageCellTextAlloc(allocator, rate_week, usage_override); + errdefer allocator.free(rate_week_str); + const last = try timefmt.formatRelativeTimeOrDashAlloc(allocator, rec.last_usage_at, now); + errdefer allocator.free(last); + const account = try sortedAccountCellAlloc(allocator, reg, account_idx); + errdefer allocator.free(account); + + rows[i] = .{ + .account_index = account_idx, + .account = account, + .plan = plan, + .rate_5h = rate_5h_str, + .rate_week = rate_week_str, + .last = last, + .depth = 0, + .is_active = isActive(reg, account_idx), + .has_error = usage_override != null, + .is_header = false, + }; + initialized_rows += 1; + selectable[i] = i; + widths.email = @max(widths.email, account.len); + widths.plan = @max(widths.plan, plan.len); + widths.rate_5h = @max(widths.rate_5h, rate_5h_str.len); + widths.rate_week = @max(widths.rate_week, rate_week_str.len); + widths.last = @max(widths.last, last.len); + } + + if (widths.email > 32) widths.email = 32; + return .{ + .items = rows, + .selectable_row_indices = selectable, + .widths = widths, + }; +} + fn buildSwitchRowsFromIndices( allocator: std.mem.Allocator, reg: *registry.Registry, @@ -183,7 +303,7 @@ pub fn buildSwitchRowsFromIndicesWithUsageOverrides( for (display.rows, 0..) |display_row, i| { if (display_row.account_index) |account_idx| { const rec = reg.accounts.items[account_idx]; - const plan = if (registry.resolveDisplayPlan(&rec)) |p| registry.planLabel(p) else "-"; + const plan = displayPlan(&rec); const rate_5h = resolveRateWindow(rec.last_usage, 300, true); const rate_week = resolveRateWindow(rec.last_usage, 10080, false); const usage_override = usageOverrideForAccount(usage_overrides, account_idx); @@ -354,3 +474,145 @@ pub fn indexWidth(count: usize) usize { } return width; } + +const SortContext = struct { + reg: *registry.Registry, + usage_overrides: ?[]const ?[]const u8, + spec: SortSpec, + now: i64, +}; + +fn sortedAccountLessThan(ctx: SortContext, lhs: usize, rhs: usize) bool { + const order = sortedAccountOrder(ctx, lhs, rhs); + return order == .lt; +} + +fn sortedAccountOrder(ctx: SortContext, lhs: usize, rhs: usize) std.math.Order { + const order = switch (ctx.spec.field) { + .account => accountOrder(ctx.reg, lhs, rhs, ctx.spec.direction), + .plan => planOrder(ctx.reg, lhs, rhs, ctx.spec.direction), + .five_hour => rateOrder(ctx.reg, ctx.usage_overrides, lhs, rhs, 300, true, ctx.now, ctx.spec.direction), + .weekly => rateOrder(ctx.reg, ctx.usage_overrides, lhs, rhs, 10080, false, ctx.now, ctx.spec.direction), + .last_activity => optionalI64Order( + ctx.reg.accounts.items[lhs].last_usage_at, + ctx.reg.accounts.items[rhs].last_usage_at, + ctx.spec.direction, + ), + }; + if (order != .eq) return order; + return accountOrder(ctx.reg, lhs, rhs, .asc); +} + +fn accountOrder(reg: *const registry.Registry, lhs: usize, rhs: usize, direction: SortDirection) std.math.Order { + const a = ®.accounts.items[lhs]; + const b = ®.accounts.items[rhs]; + const email_order = maybeReverseOrder(std.mem.order(u8, a.email, b.email), direction); + if (email_order != .eq) return email_order; + + const a_label = stableAccountLabel(a); + const b_label = stableAccountLabel(b); + const label_order = maybeReverseOrder(std.mem.order(u8, a_label, b_label), direction); + if (label_order != .eq) return label_order; + + return maybeReverseOrder(std.mem.order(u8, a.account_key, b.account_key), direction); +} + +fn planOrder(reg: *const registry.Registry, lhs: usize, rhs: usize, direction: SortDirection) std.math.Order { + const a = ®.accounts.items[lhs]; + const b = ®.accounts.items[rhs]; + return maybeReverseOrder(std.mem.order(u8, displayPlan(a), displayPlan(b)), direction); +} + +fn rateOrder( + reg: *const registry.Registry, + usage_overrides: ?[]const ?[]const u8, + lhs: usize, + rhs: usize, + minutes: i64, + fallback_primary: bool, + now: i64, + direction: SortDirection, +) std.math.Order { + return optionalI64Order( + rateSortValue(reg, usage_overrides, lhs, minutes, fallback_primary, now), + rateSortValue(reg, usage_overrides, rhs, minutes, fallback_primary, now), + direction, + ); +} + +fn rateSortValue( + reg: *const registry.Registry, + usage_overrides: ?[]const ?[]const u8, + account_idx: usize, + minutes: i64, + fallback_primary: bool, + now: i64, +) ?i64 { + if (usageOverrideForAccount(usage_overrides, account_idx) != null) return null; + const window = resolveRateWindow(reg.accounts.items[account_idx].last_usage, minutes, fallback_primary) orelse return null; + const reset_at = window.resets_at orelse return null; + if (now >= reset_at) return 100; + return remainingPercent(window.used_percent); +} + +fn optionalI64Order(lhs: ?i64, rhs: ?i64, direction: SortDirection) std.math.Order { + if (lhs == null and rhs == null) return .eq; + if (lhs == null) return .gt; + if (rhs == null) return .lt; + return maybeReverseOrder(intOrder(i64, lhs.?, rhs.?), direction); +} + +fn intOrder(comptime T: type, lhs: T, rhs: T) std.math.Order { + if (lhs < rhs) return .lt; + if (lhs > rhs) return .gt; + return .eq; +} + +fn maybeReverseOrder(order: std.math.Order, direction: SortDirection) std.math.Order { + if (direction == .asc) return order; + return switch (order) { + .lt => .gt, + .gt => .lt, + .eq => .eq, + }; +} + +fn sortedAccountCellAlloc( + allocator: std.mem.Allocator, + reg: *const registry.Registry, + account_idx: usize, +) ![]u8 { + const rec = ®.accounts.items[account_idx]; + if (sameEmailAccountCount(reg, rec.email) <= 1) return allocator.dupe(u8, rec.email); + + const fallback = displayPlan(rec); + const label = try display_rows.buildPreferredAccountLabelAlloc(allocator, rec, fallback); + defer allocator.free(label); + return std.fmt.allocPrint(allocator, "{s} ({s})", .{ rec.email, label }); +} + +fn sameEmailAccountCount(reg: *const registry.Registry, email: []const u8) usize { + var count: usize = 0; + for (reg.accounts.items) |rec| { + if (std.mem.eql(u8, rec.email, email)) count += 1; + } + return count; +} + +fn stableAccountLabel(rec: *const registry.AccountRecord) []const u8 { + if (rec.alias.len != 0) return rec.alias; + if (rec.account_name) |account_name| { + if (account_name.len != 0) return account_name; + } + return displayPlan(rec); +} + +fn displayPlan(rec: *const registry.AccountRecord) []const u8 { + if (rec.auth_mode != null and rec.auth_mode.? == .apikey) return "API_KEY"; + return if (registry.resolveDisplayPlan(rec)) |plan| registry.planLabel(plan) else "-"; +} + +fn isActive(reg: *const registry.Registry, account_idx: usize) bool { + const active = reg.active_account_key orelse return false; + return std.mem.eql(u8, active, reg.accounts.items[account_idx].account_key); +} diff --git a/src/cli/tui.zig b/src/cli/tui.zig index 045bc06a..7cb105e3 100644 --- a/src/cli/tui.zig +++ b/src/cli/tui.zig @@ -114,11 +114,17 @@ pub const TuiNavigation = enum { scroll_down, }; +pub const TuiMouseClick = struct { + col: usize, + row: usize, +}; + pub const TuiEscapeClassification = union(enum) { incomplete, ignore, keyboard_enhancement_supported, navigation: TuiNavigation, + mouse_click: TuiMouseClick, }; pub const TuiEscapeAction = enum { @@ -134,11 +140,13 @@ pub const TuiEscapeAction = enum { end, scroll_up, scroll_down, + mouse_click, keyboard_enhancement_supported, }; pub const TuiEscapeReadResult = struct { action: TuiEscapeAction, + mouse_click: ?TuiMouseClick = null, buffered_bytes_consumed: usize, }; @@ -169,6 +177,7 @@ pub const TuiInputKey = union(enum) { quit, backspace, redraw, + mouse_click: TuiMouseClick, byte: u8, }; @@ -228,13 +237,13 @@ else }.call; pub fn writeTuiEnterTo(out: *std.Io.Writer) !void { - try out.writeAll("\x1b[?1049h\x1b[?25l\x1b[?1007h"); + try out.writeAll("\x1b[?1049h\x1b[?25l\x1b[?1007h\x1b[?1000h\x1b[?1006h"); try out.writeAll("\x1b[?u\x1b[>7u"); try out.writeAll("\x1b[H\x1b[J"); } pub fn writeTuiExitTo(out: *std.Io.Writer) !void { - try out.writeAll("\x1b[<1u\x1b[?1007l\x1b[?25h\x1b[?1049l"); + try out.writeAll("\x1b[<1u\x1b[?1006l\x1b[?1000l\x1b[?1007l\x1b[?25h\x1b[?1049l"); } pub fn writeTuiResetFrameTo(out: *std.Io.Writer) !void { @@ -273,9 +282,9 @@ pub fn writeTuiFrameTo(out: *std.Io.Writer, frame: []const u8, previous_line_cou pub fn switchTuiFooterText(is_windows: bool) []const u8 { return if (is_windows) - "Keys: Up/Down or j/k, 1-9 type, Enter select, Esc or q quit\n" + "Keys: Up/Down or j/k, 1-9 type, click headers sort, Enter select, Esc or q quit\n" else - "Keys: ↑/↓ or j/k, 1-9 type, Enter select, Esc or q quit\n"; + "Keys: ↑/↓ or j/k, 1-9 type, click headers sort, Enter select, Esc or q quit\n"; } pub fn writeSwitchTuiFooter(out: *std.Io.Writer, use_color: bool) !void { @@ -288,9 +297,9 @@ pub fn writeSwitchTuiFooterBounded(out: *std.Io.Writer, use_color: bool, max_col pub fn removeTuiFooterText(is_windows: bool) []const u8 { return if (is_windows) - "Keys: Up/Down or j/k move, Space toggle, 1-9 type, Enter delete, Esc or q quit\n" + "Keys: Up/Down or j/k move, Space toggle, 1-9 type, click headers sort, Enter delete, Esc or q quit\n" else - "Keys: ↑/↓ or j/k move, Space toggle, 1-9 type, Enter delete, Esc or q quit\n"; + "Keys: ↑/↓ or j/k move, Space toggle, 1-9 type, click headers sort, Enter delete, Esc or q quit\n"; } pub fn writeRemoveTuiFooter(out: *std.Io.Writer, use_color: bool) !void { @@ -303,9 +312,9 @@ pub fn writeRemoveTuiFooterBounded(out: *std.Io.Writer, use_color: bool, max_col pub fn listTuiFooterText(is_windows: bool) []const u8 { return if (is_windows) - "Keys: Up/Down scroll, PgUp/PgDn page, Home/End jump, Esc or q quit\n" + "Keys: Up/Down scroll, PgUp/PgDn page, Home/End jump, click headers sort, Esc or q quit\n" else - "Keys: ↑/↓ scroll, PgUp/PgDn page, Home/End jump, Esc or q quit\n"; + "Keys: ↑/↓ scroll, PgUp/PgDn page, Home/End jump, click headers sort, Esc or q quit\n"; } pub fn writeListTuiFooter(out: *std.Io.Writer, use_color: bool) !void { @@ -494,6 +503,9 @@ pub const TuiSession = struct { .end => appendTuiInputKey(keys, &key_count, .end), .scroll_up => appendTuiInputKey(keys, &key_count, .scroll_up), .scroll_down => appendTuiInputKey(keys, &key_count, .scroll_down), + .mouse_click => if (escape.mouse_click) |mouse_click| { + appendTuiInputKey(keys, &key_count, .{ .mouse_click = mouse_click }); + }, .quit => appendTuiInputKey(keys, &key_count, .quit), .keyboard_enhancement_supported => self.keyboard_enhancement_supported = true, .ignore => {}, @@ -695,11 +707,11 @@ pub fn classifyTuiEscapeSuffix(seq: []const u8) TuiEscapeClassification { if (final >= '@' and final <= '~') break :blk .ignore; break :blk .incomplete; } - const first_semicolon = std.mem.indexOfScalar(u8, seq[2 .. seq.len - 1], ';') orelse break :blk .ignore; - const button_code = std.fmt.parseInt(usize, seq[2 .. 2 + first_semicolon], 10) catch break :blk .ignore; - break :blk switch (button_code) { + const mouse = parseSgrMouse(seq) orelse break :blk .ignore; + break :blk switch (mouse.button_code) { 64 => .{ .navigation = .scroll_up }, 65 => .{ .navigation = .scroll_down }, + 0 => if (mouse.pressed) .{ .mouse_click = .{ .col = mouse.col, .row = mouse.row } } else .ignore, else => .ignore, }; } @@ -749,6 +761,36 @@ pub fn classifyTuiEscapeSuffix(seq: []const u8) TuiEscapeClassification { }; } +const SgrMouseEvent = struct { + button_code: usize, + col: usize, + row: usize, + pressed: bool, +}; + +fn parseSgrMouse(seq: []const u8) ?SgrMouseEvent { + if (seq.len < "[<0;1;1M".len or seq[0] != '[' or seq[1] != '<') return null; + const final = seq[seq.len - 1]; + if (final != 'M' and final != 'm') return null; + + var parts = std.mem.splitScalar(u8, seq[2 .. seq.len - 1], ';'); + const button_raw = parts.next() orelse return null; + const col_raw = parts.next() orelse return null; + const row_raw = parts.next() orelse return null; + if (parts.next() != null) return null; + + const button_code = std.fmt.parseInt(usize, button_raw, 10) catch return null; + const col = std.fmt.parseInt(usize, col_raw, 10) catch return null; + const row = std.fmt.parseInt(usize, row_raw, 10) catch return null; + if (col == 0 or row == 0) return null; + return .{ + .button_code = button_code, + .col = col, + .row = row, + .pressed = final == 'M', + }; +} + pub fn readTuiEscapeAction( tty: std.Io.File, buffered_tail: []const u8, @@ -778,6 +820,11 @@ pub fn readTuiEscapeAction( .buffered_bytes_consumed = buffered_bytes_consumed, }; }, + .mouse_click => |mouse_click| return .{ + .action = .mouse_click, + .mouse_click = mouse_click, + .buffered_bytes_consumed = buffered_bytes_consumed, + }, .keyboard_enhancement_supported => return .{ .action = .keyboard_enhancement_supported, .buffered_bytes_consumed = buffered_bytes_consumed, diff --git a/src/workflows/list.zig b/src/workflows/list.zig index b8ee8645..dc0f5245 100644 --- a/src/workflows/list.zig +++ b/src/workflows/list.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const app_runtime = @import("../core/runtime.zig"); const cli = @import("../cli/root.zig"); const format = @import("../tui/table.zig"); const registry = @import("../registry/root.zig"); @@ -96,5 +97,17 @@ pub fn handleList(allocator: std.mem.Allocator, codex_home: []const u8, opts: cl defaultAccountFetcher, account_api_enabled, ); + if (shouldUseInteractiveList()) { + try cli.live.viewAccountsWithSortableTable(allocator, .{ + .reg = ®, + .usage_overrides = usage_state.usage_overrides, + }); + return; + } try format.printAccountsWithUsageOverrides(®, usage_state.usage_overrides); } + +fn shouldUseInteractiveList() bool { + return (std.Io.File.stdin().isTty(app_runtime.io()) catch false) and + (std.Io.File.stdout().isTty(app_runtime.io()) catch false); +} diff --git a/tests/cli_picker_test.zig b/tests/cli_picker_test.zig index f6745051..3d35ef96 100644 --- a/tests/cli_picker_test.zig +++ b/tests/cli_picker_test.zig @@ -55,6 +55,37 @@ test "Scenario: Given q quit input when checking switch picker helpers then both try std.testing.expect(!isQuitKey('j')); } +test "Scenario: Given list header mouse clicks when mapping them then column sort fields are returned" { + const rows = cli.rows.SwitchRows{ + .items = &[_]SwitchRow{}, + .selectable_row_indices = &[_]usize{}, + .widths = .{ + .email = 10, + .plan = 4, + .rate_5h = 5, + .rate_week = 6, + .last = 8, + }, + }; + const idx_width: usize = 2; + + try std.testing.expectEqual(cli.rows.SortField.account, live_tui.listHeaderSortFieldForClick(&rows, idx_width, null, .{ .col = 6, .row = 1 }).?); + try std.testing.expectEqual(cli.rows.SortField.plan, live_tui.listHeaderSortFieldForClick(&rows, idx_width, null, .{ .col = 18, .row = 1 }).?); + try std.testing.expectEqual(cli.rows.SortField.five_hour, live_tui.listHeaderSortFieldForClick(&rows, idx_width, null, .{ .col = 24, .row = 1 }).?); + try std.testing.expectEqual(cli.rows.SortField.weekly, live_tui.listHeaderSortFieldForClick(&rows, idx_width, null, .{ .col = 31, .row = 1 }).?); + try std.testing.expectEqual(cli.rows.SortField.last_activity, live_tui.listHeaderSortFieldForClick(&rows, idx_width, null, .{ .col = 39, .row = 1 }).?); + try std.testing.expect(live_tui.listHeaderSortFieldForClick(&rows, idx_width, null, .{ .col = 18, .row = 2 }) == null); + try std.testing.expectEqual(cli.rows.SortField.account, live_tui.switchHeaderSortFieldForClick(&rows, idx_width, 2, null, .{ .col = 6, .row = 2 }).?); + try std.testing.expect(live_tui.switchHeaderSortFieldForClick(&rows, idx_width, 2, null, .{ .col = 6, .row = 1 }) == null); + try std.testing.expectEqual(cli.rows.SortField.account, live_tui.removeHeaderSortFieldForClick(&rows, idx_width, 3, null, .{ .col = 10, .row = 3 }).?); + try std.testing.expectEqual(cli.rows.SortField.plan, live_tui.removeHeaderSortFieldForClick(&rows, idx_width, 3, null, .{ .col = 22, .row = 3 }).?); + + const first = live_tui.toggledSortSpec(null, .plan); + try std.testing.expectEqual(cli.rows.SortDirection.asc, first.direction); + const second = live_tui.toggledSortSpec(first, .plan); + try std.testing.expectEqual(cli.rows.SortDirection.desc, second.direction); +} + fn makeTestRegistry() registry.Registry { return .{ .schema_version = registry.current_schema_version, @@ -1372,15 +1403,15 @@ test "Scenario: Given live screen status and footers with color when rendering t test "Scenario: Given Windows console labels when rendering unicode-prone output then ASCII fallbacks are used" { try std.testing.expectEqualStrings( - "Keys: Up/Down or j/k, 1-9 type, Enter select, Esc or q quit\n", + "Keys: Up/Down or j/k, 1-9 type, click headers sort, Enter select, Esc or q quit\n", switchTuiFooterText(true), ); try std.testing.expectEqualStrings( - "Keys: Up/Down or j/k move, Space toggle, 1-9 type, Enter delete, Esc or q quit\n", + "Keys: Up/Down or j/k move, Space toggle, 1-9 type, click headers sort, Enter delete, Esc or q quit\n", removeTuiFooterText(true), ); try std.testing.expectEqualStrings( - "Keys: Up/Down scroll, PgUp/PgDn page, Home/End jump, Esc or q quit\n", + "Keys: Up/Down scroll, PgUp/PgDn page, Home/End jump, click headers sort, Esc or q quit\n", listTuiFooterText(true), ); try std.testing.expectEqualStrings("[+]", importReportMarker(.imported, true)); @@ -1390,15 +1421,15 @@ test "Scenario: Given Windows console labels when rendering unicode-prone output test "Scenario: Given non-Windows console labels when rendering unicode-prone output then the richer glyphs remain" { try std.testing.expectEqualStrings( - "Keys: ↑/↓ or j/k, 1-9 type, Enter select, Esc or q quit\n", + "Keys: ↑/↓ or j/k, 1-9 type, click headers sort, Enter select, Esc or q quit\n", switchTuiFooterText(false), ); try std.testing.expectEqualStrings( - "Keys: ↑/↓ or j/k move, Space toggle, 1-9 type, Enter delete, Esc or q quit\n", + "Keys: ↑/↓ or j/k move, Space toggle, 1-9 type, click headers sort, Enter delete, Esc or q quit\n", removeTuiFooterText(false), ); try std.testing.expectEqualStrings( - "Keys: ↑/↓ scroll, PgUp/PgDn page, Home/End jump, Esc or q quit\n", + "Keys: ↑/↓ scroll, PgUp/PgDn page, Home/End jump, click headers sort, Esc or q quit\n", listTuiFooterText(false), ); try std.testing.expectEqualStrings("✓", importReportMarker(.imported, false)); diff --git a/tests/tui_session_test.zig b/tests/tui_session_test.zig index 6bf51fa4..2b977a5e 100644 --- a/tests/tui_session_test.zig +++ b/tests/tui_session_test.zig @@ -78,6 +78,22 @@ test "Scenario: Given long SGR mouse wheel escape suffix when reading it then th try std.testing.expectEqual(@as(usize, "[<65;120;40M".len), result.buffered_bytes_consumed); } +test "Scenario: Given SGR mouse click escape suffix when classifying it then click coordinates are preserved" { + switch (classifyTuiEscapeSuffix("[<0;12;1M")) { + .mouse_click => |click| { + try std.testing.expectEqual(@as(usize, 12), click.col); + try std.testing.expectEqual(@as(usize, 1), click.row); + }, + else => return error.TestUnexpectedResult, + } + + const result = try readTuiEscapeAction(std.Io.File.stdin(), "[<0;12;1M", 0, 0); + try std.testing.expectEqual(TuiEscapeAction.mouse_click, result.action); + try std.testing.expect(result.mouse_click != null); + try std.testing.expectEqual(@as(usize, 12), result.mouse_click.?.col); + try std.testing.expectEqual(@as(usize, 1), result.mouse_click.?.row); +} + 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~")); @@ -93,15 +109,15 @@ test "Scenario: Given shared TUI screen lifecycle when writing it then switch an try writeTuiExitTo(&aw.writer); try std.testing.expectEqualStrings( - "\x1b[?1049h\x1b[?25l\x1b[?1007h\x1b[?u\x1b[>7u" ++ + "\x1b[?1049h\x1b[?25l\x1b[?1007h\x1b[?1000h\x1b[?1006h\x1b[?u\x1b[>7u" ++ "\x1b[H\x1b[J" ++ - "\x1b[<1u\x1b[?1007l\x1b[?25h\x1b[?1049l", + "\x1b[<1u\x1b[?1006l\x1b[?1000l\x1b[?1007l\x1b[?25h\x1b[?1049l", aw.written(), ); try std.testing.expect(std.mem.indexOf(u8, aw.written(), "\x1b[?1007h") != null); try std.testing.expect(std.mem.indexOf(u8, aw.written(), "\x1b[?1007l") != null); - try std.testing.expect(std.mem.indexOf(u8, aw.written(), "\x1b[?1000h") == null); - try std.testing.expect(std.mem.indexOf(u8, aw.written(), "\x1b[?1006h") == null); + try std.testing.expect(std.mem.indexOf(u8, aw.written(), "\x1b[?1000h") != null); + try std.testing.expect(std.mem.indexOf(u8, aw.written(), "\x1b[?1006h") != null); } test "Scenario: Given shared TUI frame redraw when writing it then it clears only the alternate screen frame instead of appending full screens" { diff --git a/tests/tui_table_test.zig b/tests/tui_table_test.zig index 8ebec328..d417a392 100644 --- a/tests/tui_table_test.zig +++ b/tests/tui_table_test.zig @@ -235,3 +235,31 @@ test "writeAccountsTable shows API_KEY in the plan column for API key auth" { try std.testing.expect(std.mem.indexOf(u8, output, "user@example.com") != null); try std.testing.expect(std.mem.indexOf(u8, output, "API_KEY") != null); } + +test "buildListRowsWithUsageOverrides sorts account rows for live list clicks" { + const gpa = std.testing.allocator; + var reg = makeTestRegistry(); + defer reg.deinit(gpa); + + try appendTestAccount(gpa, ®, "user-1::acc-1", "bravo@example.com", "", .free); + try appendTestAccount(gpa, ®, "user-2::acc-2", "alpha@example.com", "", .team); + try appendTestAccount(gpa, ®, "user-3::acc-3", "charlie@example.com", "", .plus); + + var account_rows = try codex_auth.cli.rows.buildListRowsWithUsageOverrides(gpa, ®, null, .{ + .field = .account, + .direction = .asc, + }); + defer account_rows.deinit(gpa); + try std.testing.expectEqualStrings("alpha@example.com", account_rows.items[0].account); + try std.testing.expectEqualStrings("bravo@example.com", account_rows.items[1].account); + try std.testing.expectEqualStrings("charlie@example.com", account_rows.items[2].account); + + var plan_rows = try codex_auth.cli.rows.buildListRowsWithUsageOverrides(gpa, ®, null, .{ + .field = .plan, + .direction = .asc, + }); + defer plan_rows.deinit(gpa); + try std.testing.expectEqualStrings("Business", plan_rows.items[0].plan); + try std.testing.expectEqualStrings("Free", plan_rows.items[1].plan); + try std.testing.expectEqualStrings("Plus", plan_rows.items[2].plan); +}