Skip to content

Commit 256878b

Browse files
committed
feat: add list row numbers and switch indentation
1 parent 4da137e commit 256878b

3 files changed

Lines changed: 179 additions & 15 deletions

File tree

docs/implement.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ Latest rollout `.jsonl` rate limit record shape (from an `event_msg` + `token_co
287287
## Output Notes
288288

289289
- Default list table columns: `ACCOUNT`, `PLAN`, `5H USAGE`, `WEEKLY`, `LAST ACTIVITY`.
290+
- `list` adds a zero-padded leading row number for selectable accounts, such as `01`, `02`.
290291
- Human-readable `list`, `switch`, and `remove` group records by email when the same email owns multiple account snapshots.
291292
- In grouped output:
292293
- the top-level email line is a header only
@@ -295,7 +296,7 @@ Latest rollout `.jsonl` rate limit record shape (from an `event_msg` + `token_co
295296
- otherwise the child label is the plan name (`team`, `plus`, etc.)
296297
- repeated plans under the same email are rendered as stable numbered labels like `team #1`, `team #2`
297298
- Single-account emails still render as one flat row; when an alias is set, that row shows `(alias)email`.
298-
- The switch/remove UI shows `ACCOUNT`, `PLAN`, `5H`, `WEEKLY`, `LAST`.
299+
- The switch/remove UI shows `ACCOUNT`, `PLAN`, `5H`, `WEEKLY`, `LAST`, and preserves grouped child indentation.
299300
- Usage limit cells show remaining percent plus reset time: `NN% (HH:MM)` for same-day resets, or `NN% (HH:MM on D Mon)` when the reset is on a different day.
300301
- `LAST ACTIVITY` is derived from `last_usage_at` and rendered as a relative time like `Now` or `2m ago`.
301302
- `PLAN` comes from the auth claim when available, and falls back to the last usage snapshot's `plan_type` (e.g. `free`, `plus`, `team`).

src/cli.zig

Lines changed: 85 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1622,7 +1622,10 @@ fn renderSwitchList(
16221622
try out.writeAll(if (is_selected) "> " else " ");
16231623
try writeIndexPadded(out, selectable_counter + 1, idx_width);
16241624
try out.writeAll(" ");
1625-
try writeTruncatedPadded(out, row.account, widths.email);
1625+
const indent: usize = @as(usize, row.depth) * 2;
1626+
const indent_to_print: usize = @min(indent, widths.email);
1627+
try writeRepeat(out, ' ', indent_to_print);
1628+
try writeTruncatedPadded(out, row.account, widths.email - indent_to_print);
16261629
try out.writeAll(" ");
16271630
try writeTruncatedPadded(out, row.plan, widths.plan);
16281631
try out.writeAll(" ");
@@ -1700,7 +1703,10 @@ fn renderRemoveList(
17001703
try out.writeAll(" ");
17011704
try writeIndexPadded(out, selectable_counter + 1, idx_width);
17021705
try out.writeAll(" ");
1703-
try writeTruncatedPadded(out, row.account, widths.email);
1706+
const indent: usize = @as(usize, row.depth) * 2;
1707+
const indent_to_print: usize = @min(indent, widths.email);
1708+
try writeRepeat(out, ' ', indent_to_print);
1709+
try writeTruncatedPadded(out, row.account, widths.email - indent_to_print);
17041710
try out.writeAll(" ");
17051711
try writeTruncatedPadded(out, row.plan, widths.plan);
17061712
try out.writeAll(" ");
@@ -1754,6 +1760,13 @@ fn writeTruncatedPadded(out: *std.Io.Writer, value: []const u8, width: usize) !v
17541760
try out.writeAll(".");
17551761
}
17561762

1763+
fn writeRepeat(out: *std.Io.Writer, ch: u8, count: usize) !void {
1764+
var i: usize = 0;
1765+
while (i < count) : (i += 1) {
1766+
try out.writeByte(ch);
1767+
}
1768+
}
1769+
17571770
const SwitchWidths = struct {
17581771
email: usize,
17591772
plan: usize,
@@ -1769,6 +1782,7 @@ const SwitchRow = struct {
17691782
rate_5h: []u8,
17701783
rate_week: []u8,
17711784
last: []u8,
1785+
depth: u8,
17721786
is_active: bool,
17731787
is_header: bool,
17741788

@@ -1820,10 +1834,11 @@ fn buildSwitchRows(allocator: std.mem.Allocator, reg: *registry.Registry) !Switc
18201834
.rate_5h = rate_5h_str,
18211835
.rate_week = rate_week_str,
18221836
.last = last,
1837+
.depth = display_row.depth,
18231838
.is_active = display_row.is_active,
18241839
.is_header = false,
18251840
};
1826-
widths.email = @max(widths.email, display_row.account_cell.len);
1841+
widths.email = @max(widths.email, display_row.account_cell.len + (@as(usize, display_row.depth) * 2));
18271842
widths.plan = @max(widths.plan, plan.len);
18281843
widths.rate_5h = @max(widths.rate_5h, rate_5h_str.len);
18291844
widths.rate_week = @max(widths.rate_week, rate_week_str.len);
@@ -1836,10 +1851,11 @@ fn buildSwitchRows(allocator: std.mem.Allocator, reg: *registry.Registry) !Switc
18361851
.rate_5h = try allocator.dupe(u8, ""),
18371852
.rate_week = try allocator.dupe(u8, ""),
18381853
.last = try allocator.dupe(u8, ""),
1854+
.depth = display_row.depth,
18391855
.is_active = false,
18401856
.is_header = true,
18411857
};
1842-
widths.email = @max(widths.email, display_row.account_cell.len);
1858+
widths.email = @max(widths.email, display_row.account_cell.len + (@as(usize, display_row.depth) * 2));
18431859
}
18441860
}
18451861
if (widths.email > 32) widths.email = 32;
@@ -1882,10 +1898,11 @@ fn buildSwitchRowsFromIndices(
18821898
.rate_5h = rate_5h_str,
18831899
.rate_week = rate_week_str,
18841900
.last = last,
1901+
.depth = display_row.depth,
18851902
.is_active = display_row.is_active,
18861903
.is_header = false,
18871904
};
1888-
widths.email = @max(widths.email, display_row.account_cell.len);
1905+
widths.email = @max(widths.email, display_row.account_cell.len + (@as(usize, display_row.depth) * 2));
18891906
widths.plan = @max(widths.plan, plan.len);
18901907
widths.rate_5h = @max(widths.rate_5h, rate_5h_str.len);
18911908
widths.rate_week = @max(widths.rate_week, rate_week_str.len);
@@ -1898,10 +1915,11 @@ fn buildSwitchRowsFromIndices(
18981915
.rate_5h = try allocator.dupe(u8, ""),
18991916
.rate_week = try allocator.dupe(u8, ""),
19001917
.last = try allocator.dupe(u8, ""),
1918+
.depth = display_row.depth,
19011919
.is_active = false,
19021920
.is_header = true,
19031921
};
1904-
widths.email = @max(widths.email, display_row.account_cell.len);
1922+
widths.email = @max(widths.email, display_row.account_cell.len + (@as(usize, display_row.depth) * 2));
19051923
}
19061924
}
19071925
if (widths.email > 32) widths.email = 32;
@@ -2046,3 +2064,64 @@ test "Scenario: Given q quit input when checking switch picker helpers then both
20462064
try std.testing.expect(isQuitKey('Q'));
20472065
try std.testing.expect(!isQuitKey('j'));
20482066
}
2067+
2068+
fn makeTestRegistry() registry.Registry {
2069+
return .{
2070+
.schema_version = registry.current_schema_version,
2071+
.active_account_key = null,
2072+
.active_account_activated_at_ms = null,
2073+
.auto_switch = registry.defaultAutoSwitchConfig(),
2074+
.api = registry.defaultApiConfig(),
2075+
.accounts = std.ArrayList(registry.AccountRecord).empty,
2076+
};
2077+
}
2078+
2079+
fn appendTestAccount(
2080+
allocator: std.mem.Allocator,
2081+
reg: *registry.Registry,
2082+
record_key: []const u8,
2083+
email: []const u8,
2084+
alias: []const u8,
2085+
plan: registry.PlanType,
2086+
) !void {
2087+
const sep = std.mem.lastIndexOf(u8, record_key, "::") orelse return error.InvalidRecordKey;
2088+
const chatgpt_user_id = record_key[0..sep];
2089+
const chatgpt_account_id = record_key[sep + 2 ..];
2090+
try reg.accounts.append(allocator, .{
2091+
.account_key = try allocator.dupe(u8, record_key),
2092+
.chatgpt_account_id = try allocator.dupe(u8, chatgpt_account_id),
2093+
.chatgpt_user_id = try allocator.dupe(u8, chatgpt_user_id),
2094+
.email = try allocator.dupe(u8, email),
2095+
.alias = try allocator.dupe(u8, alias),
2096+
.account_name = null,
2097+
.plan = plan,
2098+
.auth_mode = .chatgpt,
2099+
.created_at = 1,
2100+
.last_used_at = null,
2101+
.last_usage = null,
2102+
.last_usage_at = null,
2103+
.last_local_rollout = null,
2104+
});
2105+
}
2106+
2107+
test "Scenario: Given grouped accounts when rendering switch list then child rows keep indentation" {
2108+
const gpa = std.testing.allocator;
2109+
var reg = makeTestRegistry();
2110+
defer reg.deinit(gpa);
2111+
2112+
try appendTestAccount(gpa, &reg, "user-1::acc-1", "user@example.com", "", .team);
2113+
reg.accounts.items[0].account_name = try gpa.dupe(u8, "Als's Workspace");
2114+
try appendTestAccount(gpa, &reg, "user-1::acc-2", "user@example.com", "", .free);
2115+
2116+
var rows = try buildSwitchRows(gpa, &reg);
2117+
defer rows.deinit(gpa);
2118+
2119+
var buffer: [2048]u8 = undefined;
2120+
var writer: std.Io.Writer = .fixed(&buffer);
2121+
const idx_width = @max(@as(usize, 2), indexWidth(rows.selectable_row_indices.len));
2122+
try renderSwitchList(&writer, &reg, rows.items, idx_width, rows.widths, null, false);
2123+
2124+
const output = writer.buffered();
2125+
try std.testing.expect(std.mem.indexOf(u8, output, "01 Als's Workspace") != null);
2126+
try std.testing.expect(std.mem.indexOf(u8, output, "02 free") != null);
2127+
}

src/format.zig

Lines changed: 92 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ fn printAccountsTable(reg: *registry.Registry) !void {
3131
var stdout: io_util.Stdout = undefined;
3232
stdout.init();
3333
const out = stdout.out();
34+
try writeAccountsTable(out, reg, colorEnabled());
35+
try out.flush();
36+
}
37+
38+
fn writeAccountsTable(out: *std.Io.Writer, reg: *registry.Registry, use_color: bool) !void {
3439
const headers = [_][]const u8{ "ACCOUNT", "PLAN", "5H USAGE", "WEEKLY USAGE", "LAST ACTIVITY" };
3540
var widths = [_]usize{
3641
headers[0].len,
@@ -40,11 +45,11 @@ fn printAccountsTable(reg: *registry.Registry) !void {
4045
headers[4].len,
4146
};
4247
const now = std.time.timestamp();
43-
const prefix_len: usize = 2;
44-
const sep_len: usize = 2;
45-
4648
var display = try display_rows.buildDisplayRows(std.heap.page_allocator, reg, null);
4749
defer display.deinit(std.heap.page_allocator);
50+
const idx_width = @max(@as(usize, 2), indexWidth(display.selectable_row_indices.len));
51+
const prefix_len: usize = 2 + idx_width + 1;
52+
const sep_len: usize = 2;
4853

4954
for (display.rows) |row| {
5055
const indent: usize = @as(usize, row.depth) * 2;
@@ -70,7 +75,6 @@ fn printAccountsTable(reg: *registry.Registry) !void {
7075

7176
adjustListWidths(&widths, prefix_len, sep_len);
7277

73-
const use_color = colorEnabled();
7478
const h0 = try truncateAlloc(headers[0], widths[0]);
7579
defer std.heap.page_allocator.free(h0);
7680
const h1 = try truncateAlloc(headers[1], widths[1]);
@@ -86,7 +90,7 @@ fn printAccountsTable(reg: *registry.Registry) !void {
8690
defer std.heap.page_allocator.free(h4);
8791

8892
if (use_color) try out.writeAll(ansi.dim);
89-
try out.writeAll(" ");
93+
try writeRepeat(out, ' ', prefix_len);
9094
try writePadded(out, h0, widths[0]);
9195
try out.writeAll(" ");
9296
try writePadded(out, h1, widths[1]);
@@ -102,6 +106,7 @@ fn printAccountsTable(reg: *registry.Registry) !void {
102106
try out.writeAll("\n");
103107
if (use_color) try out.writeAll(ansi.reset);
104108

109+
var selectable_counter: usize = 0;
105110
for (display.rows) |row| {
106111
if (row.account_index) |account_idx| {
107112
const rec = reg.accounts.items[account_idx];
@@ -134,6 +139,8 @@ fn printAccountsTable(reg: *registry.Registry) !void {
134139
}
135140
}
136141
try out.writeAll(if (row.is_active) "* " else " ");
142+
try writeIndexPadded(out, selectable_counter + 1, idx_width);
143+
try out.writeAll(" ");
137144
try writeRepeat(out, ' ', indent_to_print);
138145
try writePadded(out, account_cell, widths[0] - indent_to_print);
139146
try out.writeAll(" ");
@@ -146,18 +153,17 @@ fn printAccountsTable(reg: *registry.Registry) !void {
146153
try writePadded(out, last_cell, widths[4]);
147154
try out.writeAll("\n");
148155
if (use_color) try out.writeAll(ansi.reset);
156+
selectable_counter += 1;
149157
} else {
150158
const account_cell = try truncateAlloc(row.account_cell, widths[0]);
151159
defer std.heap.page_allocator.free(account_cell);
152160
if (use_color) try out.writeAll(ansi.dim);
153-
try out.writeAll(" ");
161+
try writeRepeat(out, ' ', prefix_len);
154162
try writePadded(out, account_cell, widths[0]);
155163
try out.writeAll("\n");
156164
if (use_color) try out.writeAll(ansi.reset);
157165
}
158166
}
159-
160-
try out.flush();
161167
}
162168

163169
fn resolveRateWindow(usage: ?registry.RateLimitSnapshot, minutes: i64, fallback_primary: bool) ?registry.RateLimitWindow {
@@ -570,6 +576,66 @@ fn truncateAlloc(value: []const u8, max_len: usize) ![]u8 {
570576
return std.fmt.allocPrint(std.heap.page_allocator, "{s}.", .{value[0 .. max_len - 1]});
571577
}
572578

579+
fn writeIndexPadded(out: *std.Io.Writer, idx: usize, width: usize) !void {
580+
var buf: [16]u8 = undefined;
581+
const idx_str = std.fmt.bufPrint(&buf, "{d}", .{idx}) catch "0";
582+
if (idx_str.len < width) {
583+
var pad: usize = width - idx_str.len;
584+
while (pad > 0) : (pad -= 1) {
585+
try out.writeAll("0");
586+
}
587+
}
588+
try out.writeAll(idx_str);
589+
}
590+
591+
fn indexWidth(count: usize) usize {
592+
var n = count;
593+
var width: usize = 1;
594+
while (n >= 10) : (n /= 10) {
595+
width += 1;
596+
}
597+
return width;
598+
}
599+
600+
fn makeTestRegistry() registry.Registry {
601+
return .{
602+
.schema_version = registry.current_schema_version,
603+
.active_account_key = null,
604+
.active_account_activated_at_ms = null,
605+
.auto_switch = registry.defaultAutoSwitchConfig(),
606+
.api = registry.defaultApiConfig(),
607+
.accounts = std.ArrayList(registry.AccountRecord).empty,
608+
};
609+
}
610+
611+
fn appendTestAccount(
612+
allocator: std.mem.Allocator,
613+
reg: *registry.Registry,
614+
record_key: []const u8,
615+
email: []const u8,
616+
alias: []const u8,
617+
plan: registry.PlanType,
618+
) !void {
619+
const sep = std.mem.lastIndexOf(u8, record_key, "::") orelse return error.InvalidRecordKey;
620+
const chatgpt_user_id = record_key[0..sep];
621+
const chatgpt_account_id = record_key[sep + 2 ..];
622+
try reg.accounts.append(allocator, .{
623+
.account_key = try allocator.dupe(u8, record_key),
624+
.chatgpt_account_id = try allocator.dupe(u8, chatgpt_account_id),
625+
.chatgpt_user_id = try allocator.dupe(u8, chatgpt_user_id),
626+
.email = try allocator.dupe(u8, email),
627+
.alias = try allocator.dupe(u8, alias),
628+
.account_name = null,
629+
.plan = plan,
630+
.auth_mode = .chatgpt,
631+
.created_at = 1,
632+
.last_used_at = null,
633+
.last_usage = null,
634+
.last_usage_at = null,
635+
.last_local_rollout = null,
636+
});
637+
}
638+
573639
test "printTableRow handles long cells without underflow" {
574640
var buffer: [256]u8 = undefined;
575641
var writer: std.Io.Writer = .fixed(&buffer);
@@ -601,3 +667,21 @@ test "formatRateLimitFullAlloc shows 100% after reset instead of dash-prefixed v
601667

602668
try std.testing.expectEqualStrings("100%", formatted);
603669
}
670+
671+
test "writeAccountsTable shows zero-padded row numbers for selectable accounts" {
672+
const gpa = std.testing.allocator;
673+
var reg = makeTestRegistry();
674+
defer reg.deinit(gpa);
675+
676+
try appendTestAccount(gpa, &reg, "user-1::acc-1", "user@example.com", "", .team);
677+
reg.accounts.items[0].account_name = try gpa.dupe(u8, "Als's Workspace");
678+
try appendTestAccount(gpa, &reg, "user-1::acc-2", "user@example.com", "", .free);
679+
680+
var buffer: [2048]u8 = undefined;
681+
var writer: std.Io.Writer = .fixed(&buffer);
682+
try writeAccountsTable(&writer, &reg, false);
683+
684+
const output = writer.buffered();
685+
try std.testing.expect(std.mem.indexOf(u8, output, "01 Als's Workspace") != null);
686+
try std.testing.expect(std.mem.indexOf(u8, output, "02 free") != null);
687+
}

0 commit comments

Comments
 (0)