diff --git a/README.md b/README.md index 8a43864..7e3883a 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 [--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] [--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 [--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 | @@ -129,23 +130,26 @@ 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. +`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 ``` @@ -154,7 +158,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`, `--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/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/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 521b6f1..a78bf51 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"; @@ -24,6 +107,476 @@ const ansi = struct { const bold = "\x1b[1m"; }; +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 { + 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, +}; + +const TuiPollResult = enum { + ready, + timeout, + closed, +}; + +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"); +} + +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"); +} + +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, 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, Enter delete, Esc or q quit\n"); + 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) { + 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_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, + + fn init() !@This() { + const input = std.Io.File.stdin(); + const output = std.Io.File.stdout(); + if (!(try input.isTty(app_runtime.io())) or !(try output.isTty(app_runtime.io()))) { + return error.TuiRequiresTty; + } + + if (comptime builtin.os.tag == .windows) { + 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 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) { + _ = 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; + } + + fn out(self: *@This()) *std.Io.Writer { + return &self.writer.interface; + } + + fn read(self: *@This(), buffer: []u8) !usize { + 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); + 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: std.Io.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, + }; + } + + 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, + }, + .closed => return .{ + .action = .quit, + .buffered_bytes_consumed = buffered_bytes_consumed, + }, + .ready => {}, + } + + const read_n = try readFileOnce(tty, 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); +} + +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; } @@ -47,7 +600,7 @@ pub const ApiMode = enum { }; pub const ListOptions = struct { - debug: bool = false, + live: bool = false, api_mode: ApiMode = .default, }; pub const LoginOptions = struct { @@ -62,11 +615,14 @@ pub const ImportOptions = struct { }; pub const SwitchOptions = struct { query: ?[]u8, + live: bool = false, + auto: 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 {}; @@ -160,11 +716,11 @@ 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`.", .{}); + if (std.mem.eql(u8, arg, "--live")) { + if (opts.live) { + return usageErrorResult(allocator, .list, "duplicate `--live` for `list`.", .{}); } - opts.debug = true; + opts.live = true; continue; } if (std.mem.eql(u8, arg, "--api")) { @@ -295,6 +851,22 @@ 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, "--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, @@ -333,12 +905,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) { + 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 `--api` or `--skip-api`.", + "`switch ` does not support `--live`, `--auto`, `--api`, or `--skip-api`.", .{}, ); } @@ -354,10 +930,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, @@ -397,18 +981,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, } } }; } @@ -657,8 +1242,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] [--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" }, }; @@ -827,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] [--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"); @@ -839,11 +1424,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] [--auto] [--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"); }, @@ -873,7 +1458,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"); }, @@ -889,6 +1474,8 @@ 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"); @@ -896,6 +1483,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"); @@ -1053,6 +1641,30 @@ pub fn printAccountNotFoundErrors(queries: []const []const u8) !void { try out.flush(); } +pub fn printSwitchRequiresTtyError() !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(" interactive switch requires a TTY.\n"); + try writeHintPrefixTo(out, use_color); + try out.writeAll(" Run `codex-auth switch` in a terminal, or narrow `codex-auth switch ` to one account.\n"); + 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); @@ -1170,6 +1782,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 = std.Io.File.stderr().writer(app_runtime.io(), &buffer); @@ -1235,76 +1868,1353 @@ 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 - selectInteractive(allocator, reg, usage_overrides) catch selectWithNumbers(allocator, reg, usage_overrides); -} - -pub fn selectAccountFromIndices(allocator: std.mem.Allocator, reg: *registry.Registry, indices: []const usize) !?[]const u8 { - return selectAccountFromIndicesWithUsageOverrides(allocator, reg, indices, null); + 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 selectInteractive(allocator, reg, usage_overrides) catch |err| switch (err) { + error.TuiRequiresTty => selectWithNumbers(allocator, reg, usage_overrides), + else => return err, + }; } -pub fn selectAccountFromIndicesWithUsageOverrides( - allocator: std.mem.Allocator, +pub const SwitchSelectionDisplay = struct { 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; - return if (comptime builtin.os.tag == .windows) - selectWithNumbersFromIndices(allocator, reg, indices, usage_overrides) - else - selectInteractiveFromIndices(allocator, reg, indices, usage_overrides) catch selectWithNumbersFromIndices(allocator, reg, indices, usage_overrides); -} +}; -pub fn selectAccountsToRemove(allocator: std.mem.Allocator, reg: *registry.Registry) !?[]usize { - return selectAccountsToRemoveWithUsageOverrides(allocator, reg, null); -} +pub const OwnedSwitchSelectionDisplay = struct { + reg: registry.Registry, + usage_overrides: []?[]const u8, -pub fn selectAccountsToRemoveWithUsageOverrides( - allocator: std.mem.Allocator, - reg: *registry.Registry, - usage_overrides: ?[]const ?[]const u8, -) !?[]usize { - if (comptime builtin.os.tag == .windows) { - return selectRemoveWithNumbers(allocator, reg, usage_overrides); + pub fn borrowed(self: *@This()) SwitchSelectionDisplay { + return .{ + .reg = &self.reg, + .usage_overrides = self.usage_overrides, + }; } - if (shouldUseNumberedRemoveSelector(false, std.Io.File.stdin().isTty(app_runtime.io()) catch false)) { - return selectRemoveWithNumbers(allocator, reg, 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; } - return selectRemoveInteractive(allocator, reg, usage_overrides) catch selectRemoveWithNumbers(allocator, reg, usage_overrides); -} +}; -pub fn shouldUseNumberedRemoveSelector(is_windows: bool, stdin_is_tty: bool) bool { - return is_windows or !stdin_is_tty; -} +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, +}; -fn isQuitInput(input: []const u8) bool { - return input.len == 1 and (input[0] == 'q' or input[0] == 'Q'); -} +pub const LiveActionOutcome = struct { + updated_display: OwnedSwitchSelectionDisplay, + action_message: ?[]u8 = null, +}; -fn isQuitKey(key: u8) bool { - return key == 'q' or key == 'Q'; -} +pub const SwitchLiveActionController = struct { + refresh: SwitchLiveController, + apply_selection: *const fn ( + context: *anyopaque, + allocator: std.mem.Allocator, + account_key: []const u8, + ) anyerror!LiveActionOutcome, + auto_switch: bool = false, +}; -fn activeSelectableIndex(rows: *const SwitchRows) ?usize { - for (rows.selectable_row_indices, 0..) |row_idx, pos| { - if (rows.items[row_idx].is_active) return pos; +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, + 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 (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); } - 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; -} + 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; + + 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); + try filterErroredRowsFromSelectableIndices(allocator, &rows); + 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(total_accounts)), + rows.widths, + selected_display_idx, + use_color, + status_line, + "", + number_buf[0..number_len], + ); + try out.flush(); + + 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 (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 => { + 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 null; + }, + .quit => return null, + .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 null; + + 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 null; + + 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 null, + .ignore => {}, + } + i += escape.buffered_bytes_consumed; + continue; + } + + if (b[i] == '\r' or b[i] == '\n') { + 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 null; + } + if (isQuitKey(b[i])) return null; + + 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 viewAccountsWithLiveUpdates( + allocator: std.mem.Allocator, + initial_display: OwnedSwitchSelectionDisplay, + controller: SwitchLiveController, +) !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; + + while (true) { + if (try controller.maybe_take_updated_display(controller.context)) |updated| { + current_display.deinit(allocator); + current_display = updated; + } + + 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); + + 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(); + + switch (try pollTuiInput(tui.input, ui_tick_ms, tui_poll_error_mask)) { + .timeout => { + try controller.maybe_start_refresh(controller.context); + continue; + }, + .closed => return, + .ready => {}, + } + + if (comptime builtin.os.tag == .windows) { + switch (try tui.readWindowsKey()) { + .quit => return, + .redraw => continue, + else => 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) { + .quit => return, + else => {}, + } + i += escape.buffered_bytes_consumed; + continue; + } + if (isQuitKey(b[i])) return; + } + } +} + +pub fn runSwitchLiveActions( + allocator: std.mem.Allocator, + initial_display: OwnedSwitchSelectionDisplay, + controller: SwitchLiveActionController, +) !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 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 action_message: ?[]u8 = null; + defer if (action_message) |message| allocator.free(message); + + 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(); + 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.?); + } + + 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]); + + 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; + auto_check_pending = controller.auto_switch; + }, + .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; + auto_check_pending = controller.auto_switch; + 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 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 { + _ = 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 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 { + _ = 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 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, + 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( @@ -1318,13 +3228,17 @@ 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); + 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(); @@ -1336,9 +3250,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( @@ -1354,13 +3267,17 @@ fn selectWithNumbersFromIndices( var rows = try buildSwitchRowsFromIndicesWithUsageOverrides(allocator, reg, indices, usage_overrides); defer rows.deinit(allocator); + try filterErroredRowsFromSelectableIndices(allocator, &rows); + 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(); @@ -1372,9 +3289,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( @@ -1386,77 +3302,147 @@ 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); + const total_accounts = accountRowCount(rows.items); + if (total_accounts == 0) return null; - var tty = try std.Io.Dir.cwd().openFile(app_runtime.io(), "/dev/tty", .{}); - defer tty.close(app_runtime.io()); - - 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 idx_width = @max(@as(usize, 2), indexWidth(rows.selectable_row_indices.len)); + const use_color = try tui.output.isTty(app_runtime.io()); + const idx_width = @max(@as(usize, 2), indexWidth(total_accounts)); const widths = rows.widths; while (true) { - try out.writeAll("\x1b[2J\x1b[H"); - 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); + 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, + reg, + rows.items, + idx_width, + widths, + selected_display_idx, + use_color, + "", + "", + number_buf[0..number_len], + ); try out.flush(); - var b: [8]u8 = undefined; - const n = try readFileOnce(tty, &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) { + if (comptime builtin.os.tag == .windows) { + switch (try tui.readWindowsKey()) { + .move_up => { + if (rows.selectable_row_indices.len != 0 and idx > 0) { idx -= 1; number_len = 0; - } else if (code == 'B' and idx + 1 < rows.selectable_row_indices.len) { + } + }, + .move_down => { + if (rows.selectable_row_indices.len != 0 and idx + 1 < rows.selectable_row_indices.len) { idx += 1; number_len = 0; } - i += 2; - continue; + }, + .enter => { + 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 (parsedDisplayedIndex(number_buf[0..number_len], total_accounts)) |displayed_idx| { + if (selectableIndexForDisplayedAccount(&rows, displayed_idx)) |selectable_idx| { + idx = selectable_idx; + } + } + } + }, + .redraw => continue, + .byte => |ch| { + if (isQuitKey(ch)) return null; + if (ch == 'k' and rows.selectable_row_indices.len != 0 and idx > 0) { + idx -= 1; + number_len = 0; + continue; + } + if (ch == 'j' and rows.selectable_row_indices.len != 0 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; + if (parsedDisplayedIndex(number_buf[0..number_len], total_accounts)) |displayed_idx| { + if (selectableIndexForDisplayedAccount(&rows, displayed_idx)) |selectable_idx| { + idx = selectable_idx; + } + } + } + }, + } + continue; + } + + var b: [8]u8 = undefined; + const n = try tui.read(&b); + 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 (rows.selectable_row_indices.len != 0 and idx > 0) { + idx -= 1; + number_len = 0; + } + }, + .move_down => { + if (rows.selectable_row_indices.len != 0 and 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') { - 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; @@ -1464,10 +3450,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; } } } @@ -1477,9 +3462,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; @@ -1569,76 +3555,146 @@ 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); + const total_accounts = accountRowCount(rows.items); + if (total_accounts == 0) return null; - var tty = try std.Io.Dir.cwd().openFile(app_runtime.io(), "/dev/tty", .{}); - defer tty.close(app_runtime.io()); - - 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 idx_width = @max(@as(usize, 2), indexWidth(rows.selectable_row_indices.len)); + const use_color = try tui.output.isTty(app_runtime.io()); + const idx_width = @max(@as(usize, 2), indexWidth(total_accounts)); const widths = rows.widths; while (true) { - try out.writeAll("\x1b[2J\x1b[H"); - 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); + 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, + reg, + rows.items, + idx_width, + widths, + selected_display_idx, + use_color, + "", + "", + number_buf[0..number_len], + ); try out.flush(); - var b: [8]u8 = undefined; - const n = try readFileOnce(tty, &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) { + if (comptime builtin.os.tag == .windows) { + switch (try tui.readWindowsKey()) { + .move_up => { + if (rows.selectable_row_indices.len != 0 and idx > 0) { idx -= 1; number_len = 0; - } else if (code == 'B' and idx + 1 < rows.selectable_row_indices.len) { + } + }, + .move_down => { + if (rows.selectable_row_indices.len != 0 and idx + 1 < rows.selectable_row_indices.len) { idx += 1; number_len = 0; } - i += 2; - continue; + }, + .enter => { + 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 (parsedDisplayedIndex(number_buf[0..number_len], total_accounts)) |displayed_idx| { + if (selectableIndexForDisplayedAccount(&rows, displayed_idx)) |selectable_idx| { + idx = selectable_idx; + } + } + } + }, + .redraw => continue, + .byte => |ch| { + if (isQuitKey(ch)) return null; + if (ch == 'k' and rows.selectable_row_indices.len != 0 and idx > 0) { + idx -= 1; + number_len = 0; + continue; + } + if (ch == 'j' and rows.selectable_row_indices.len != 0 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; + if (parsedDisplayedIndex(number_buf[0..number_len], total_accounts)) |displayed_idx| { + if (selectableIndexForDisplayedAccount(&rows, displayed_idx)) |selectable_idx| { + idx = selectable_idx; + } + } + } + }, + } + continue; + } + + var b: [8]u8 = undefined; + const n = try tui.read(&b); + 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 (rows.selectable_row_indices.len != 0 and idx > 0) { + idx -= 1; + number_len = 0; + } + }, + .move_down => { + if (rows.selectable_row_indices.len != 0 and 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') { - 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; @@ -1646,10 +3702,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; } } } @@ -1659,9 +3714,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; @@ -1679,25 +3735,13 @@ fn selectRemoveInteractive( var rows = try buildSwitchRowsWithUsageOverrides(allocator, reg, usage_overrides); defer rows.deinit(allocator); - var tty = try std.Io.Dir.cwd().openFile(app_runtime.io(), "/dev/tty", .{}); - defer tty.close(app_runtime.io()); - - 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; @@ -1706,33 +3750,115 @@ fn selectRemoveInteractive( const widths = rows.widths; while (true) { - try out.writeAll("\x1b[2J\x1b[H"); - try out.writeAll("Select accounts to delete:\n\n"); + try tui.resetFrame(); + 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"); - 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; - const n = try readFileOnce(tty, &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) { + if (comptime builtin.os.tag == .windows) { + switch (try tui.readWindowsKey()) { + .move_up => { + if (idx > 0) { idx -= 1; number_len = 0; - } else if (code == 'B' and idx + 1 < rows.selectable_row_indices.len) { + } + }, + .move_down => { + if (idx + 1 < rows.selectable_row_indices.len) { idx += 1; number_len = 0; } - i += 2; - continue; + }, + .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; + 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 (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') { @@ -1793,6 +3919,90 @@ 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, + action_line: []const u8, + number_input: []const u8, +) !void { + 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) { + 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 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( out: *std.Io.Writer, reg: *registry.Registry, @@ -1819,7 +4029,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); @@ -1834,7 +4044,7 @@ fn renderSwitchList( continue; } - const is_selected = 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) { @@ -1852,7 +4062,7 @@ fn renderSwitchList( } } try out.writeAll(if (is_selected) "> " else " "); - try writeIndexPadded(out, selectable_counter + 1, 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); @@ -1871,7 +4081,7 @@ fn renderSwitchList( } try out.writeAll("\n"); if (use_color) try out.writeAll(ansi.reset); - selectable_counter += 1; + displayed_counter += 1; } } @@ -2045,6 +4255,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, @@ -2384,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(); @@ -2427,6 +4672,160 @@ 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 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(); + 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 ") != 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); +} + +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 24ba4a8..a7c6fdf 100644 --- a/src/main.zig +++ b/src/main.zig @@ -9,13 +9,14 @@ 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"; 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 = 5; +const switch_live_api_refresh_interval_ms: i64 = 30_000; +const switch_live_local_refresh_interval_ms: i64 = 10_000; fn getEnvMap(allocator: std.mem.Allocator) !std.process.Environ.Map { return try app_runtime.currentEnviron().createMap(allocator); @@ -29,6 +30,14 @@ fn getEnvVarOwned(allocator: std.mem.Allocator, name: []const u8) ![]u8 { return try allocator.dupe(u8, value); } +fn nowMilliseconds() i64 { + return std.Io.Timestamp.now(app_runtime.io(), .real).toMilliseconds(); +} + +fn nowSeconds() i64 { + return std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); +} + const AccountFetchFn = *const fn ( allocator: std.mem.Allocator, access_token: []const u8, @@ -109,40 +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()); - - try self.writer.print(fmt, args); - try self.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| { @@ -213,7 +188,9 @@ 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 err == error.RemoveSelectionRequiresTty or err == error.InvalidRemoveSelectionInput; @@ -245,10 +222,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, @@ -395,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, ); @@ -413,63 +385,43 @@ pub fn refreshForegroundUsageForDisplay( codex_home: []const u8, reg: *registry.Registry, ) !ForegroundUsageRefreshState { - return refreshForegroundUsageForDisplayWithBatchFetcherAndDebugUsingApiEnabled( - allocator, - codex_home, - reg, - null, - reg.api.usage, - ); -} - -pub fn refreshForegroundUsageForDisplayWithBatchFetcherAndDebug( - allocator: std.mem.Allocator, - codex_home: []const u8, - reg: *registry.Registry, - debug_logger: ?*ForegroundUsageDebugLogger, -) !ForegroundUsageRefreshState { - return refreshForegroundUsageForDisplayWithBatchFetcherAndDebugUsingApiEnabled( + return refreshForegroundUsageForDisplayWithBatchFetcherUsingApiEnabled( allocator, codex_home, reg, - debug_logger, reg.api.usage, ); } -fn refreshForegroundUsageForDisplayWithBatchFetcherAndDebugUsingApiEnabled( +fn refreshForegroundUsageForDisplayWithBatchFetcherUsingApiEnabled( 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, ); @@ -482,88 +434,64 @@ 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( +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 refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitAndDebugUsingApiEnabled( + return refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitUsingApiEnabledAndPersist( allocator, codex_home, reg, usage_fetcher, - null, + batch_fetcher, pool_init, - debug_logger, - reg.api.usage, - false, + usage_api_enabled, + batch_fetch_failures_are_fatal, + true, ); } -fn refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitAndDebugUsingApiEnabled( +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, ) !ForegroundUsageRefreshState { 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)) { - 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); + if (persist_registry) try registry.saveRegistry(allocator, codex_home, reg); } 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 { @@ -580,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( @@ -595,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; }, @@ -622,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; @@ -652,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); } } @@ -691,14 +597,10 @@ fn refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitAndDebugUsingApiEn } } - if (registry_changed) { + if (persist_registry and registry_changed) { try registry.saveRegistry(allocator, codex_home, reg); } - if (debug_logger) |logger| { - try printForegroundUsageDebugDone(logger, &state); - } - return state; } @@ -716,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 { @@ -724,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, @@ -734,7 +632,6 @@ const ForegroundUsageWorkerQueue = struct { idx, self.usage_fetcher, self.results, - self.debug_context, ); } } @@ -746,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; } @@ -760,7 +656,6 @@ fn runForegroundUsageRefreshWorkersConcurrently( .reg = reg, .usage_fetcher = usage_fetcher, .results = results, - .debug_context = debug_context, }; const helper_count = worker_count - 1; @@ -789,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); } } @@ -806,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(); @@ -814,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; }; @@ -852,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( @@ -899,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, @@ -1099,6 +778,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, @@ -1115,8 +814,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( @@ -1490,15 +1190,54 @@ 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, + loaded.refresh_error_name, + ); + 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); @@ -1512,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); @@ -1601,13 +1332,15 @@ 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); + std.debug.assert(!opts.live); + std.debug.assert(!opts.auto); var resolution = try resolveSwitchQueryLocally(allocator, ®, query); defer resolution.deinit(allocator); @@ -1618,96 +1351,882 @@ fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. return error.AccountNotFound; }, .direct => |account_key| account_key, - .multiple => |matches| try cli.selectAccountFromIndicesWithUsageOverrides( + .multiple => |matches| cli.selectAccountFromIndicesWithUsageOverrides( allocator, ®, matches.items, null, - ), + ) 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, ®, selected_account_key.?); try registry.saveRegistry(allocator, codex_home, ®); return; } - if (apiModeUsesStoredDataOnly(opts.api_mode)) { - const selected_account_key = try cli.selectAccount(allocator, ®); + + if (!opts.live) { + 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); + + 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, ®, selected_account_key.?); - try registry.saveRegistry(allocator, codex_home, ®); + try registry.activateAccountByKey(allocator, codex_home, &loaded.display.reg, selected_account_key.?); + try registry.saveRegistry(allocator, codex_home, &loaded.display.reg); return; } - const usage_api_enabled = apiModeUsesApi(reg.api.usage, opts.api_mode); - const account_api_enabled = apiModeUsesApi(reg.api.account, opts.api_mode); - - try ensureForegroundNodeAvailableWithApiEnabled( - allocator, + 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, - usage_api_enabled, - account_api_enabled, + strict_refresh, ); + var initial_display: ?cli.OwnedSwitchSelectionDisplay = loaded.display; + errdefer if (initial_display) |*display| display.deinit(live_allocator); - var usage_state = try refreshForegroundUsageForDisplayWithBatchFetcherAndDebugUsingApiEnabled( - allocator, - codex_home, - ®, - null, - usage_api_enabled, - ); - defer usage_state.deinit(allocator); - try maybeRefreshForegroundAccountNamesWithAccountApiEnabled( - allocator, + var runtime = SwitchLiveRuntime.init( + live_allocator, codex_home, - ®, .switch_account, - defaultAccountFetcher, - account_api_enabled, + opts.api_mode, + strict_refresh, + loaded.policy, + loaded.refresh_error_name, ); + defer runtime.deinit(); + + const controller: cli.SwitchLiveActionController = .{ + .refresh = .{ + .context = @ptrCast(&runtime), + .maybe_start_refresh = switchLiveRuntimeMaybeStartRefresh, + .maybe_take_updated_display = switchLiveRuntimeMaybeTakeUpdatedDisplay, + .build_status_line = switchLiveRuntimeBuildStatusLine, + }, + .apply_selection = switchLiveRuntimeApplySelection, + .auto_switch = opts.auto, + }; - const selected_account_key = try cli.selectAccountWithUsageOverrides(allocator, ®, usage_state.usage_overrides); - if (selected_account_key == null) return; - - try registry.activateAccountByKey(allocator, codex_home, ®, selected_account_key.?); - try registry.saveRegistry(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; + }; } -pub fn resolveSwitchQueryLocally( +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, + refresh_error_name: ?[]u8 = null, +}; + +const SwitchLiveRuntime = struct { allocator: std.mem.Allocator, - reg: *registry.Registry, - query: []const u8, -) !SwitchQueryResolution { - if (try findAccountIndexByDisplayNumber(allocator, reg, query)) |account_idx| { - return .{ .direct = reg.accounts.items[account_idx].account_key }; + 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, + 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, + target: ForegroundUsageRefreshTarget, + api_mode: cli.ApiMode, + strict_refresh: bool, + initial_policy: SwitchLiveRefreshPolicy, + initial_refresh_error_name: ?[]u8, + ) @This() { + const io_impl = std.Io.Threaded.init(allocator, .{ + .concurrent_limit = .limited(1), + }); + const now_ms = nowMilliseconds(); + 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, + .mode_label = initial_policy.label, + .last_refresh_error_name = initial_refresh_error_name, + }; } - var matches = try findMatchingAccounts(allocator, reg, query); - if (matches.items.len == 0) { - matches.deinit(allocator); - return .not_found; + 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; } - if (matches.items.len == 1) { - defer matches.deinit(allocator); - return .{ .direct = reg.accounts.items[matches.items[0]].account_key }; + + 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); } - return .{ .multiple = matches }; -} -fn handleConfig(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.ConfigOptions) !void { - switch (opts) { - .auto_switch => |auto_opts| try auto.handleAutoCommand(allocator, codex_home, auto_opts), - .api => |action| try auto.handleApiCommand(allocator, codex_home, action), + fn maybeStartRefresh(self: *@This()) void { + const io = self.io_impl.io(); + const now_ms = nowMilliseconds(); + + 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 = nowMilliseconds(); + 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 freeOwnedStrings(allocator: std.mem.Allocator, items: []const []const u8) void { - for (items) |item| allocator.free(@constCast(item)); -} + fn maybeTakeUpdatedDisplay(self: *@This()) ?cli.OwnedSwitchSelectionDisplay { + const io = self.io_impl.io(); + var future: ?std.Io.Future(void) = null; + var display: ?cli.OwnedSwitchSelectionDisplay = null; -pub fn findMatchingAccounts( - allocator: std.mem.Allocator, + 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 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 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(); + const now_ms = nowMilliseconds(); + + var in_flight = false; + var next_refresh_not_before_ms: i64 = now_ms; + var mode_label: []const u8 = "local"; + 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; + 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, "Refresh running") + else if (next_refresh_not_before_ms <= now_ms) + try allocator.dupe(u8, "Refresh due") + else + 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 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); + + return std.fmt.allocPrint( + allocator, + "Live refresh: {s} | {s}{s}", + .{ mode_label, refresh_state, error_suffix }, + ); + } +}; + +fn switchLiveRefreshPolicy(reg: *const registry.Registry, api_mode: cli.ApiMode) SwitchLiveRefreshPolicy { + 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 loadStoredSwitchSelectionDisplay( + allocator: std.mem.Allocator, + codex_home: []const u8, + api_mode: cli.ApiMode, +) !SwitchLoadedDisplay { + 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 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, +) !SwitchLoadedDisplay { + 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); + + ensureForegroundNodeAvailableWithApiEnabled( + allocator, + codex_home, + &refreshed, + target, + initial_policy.usage_api_enabled, + initial_policy.account_api_enabled, + ) catch |err| switch (err) { + error.OutOfMemory => return err, + else => { + if (strict_refresh) return err; + refreshed.deinit(allocator); + return loadStoredSwitchSelectionDisplayWithRefreshError(allocator, codex_home, api_mode, err); + }, + }; + + var usage_state = refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitUsingApiEnabledAndPersist( + allocator, + codex_home, + &refreshed, + usage_api.fetchUsageForAuthPathDetailed, + usage_api.fetchUsageForAuthPathsDetailedBatch, + initForegroundUsagePool, + 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 loadStoredSwitchSelectionDisplayWithRefreshError(allocator, codex_home, api_mode, err); + }, + }; + errdefer usage_state.deinit(allocator); + + _ = maybeRefreshForegroundAccountNamesWithAccountApiEnabledAndPersist( + allocator, + codex_home, + &refreshed, + 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 loadStoredSwitchSelectionDisplayWithRefreshError(allocator, codex_home, api_mode, err); + }, + }; + + var latest = try registry.loadRegistry(allocator, codex_home); + errdefer latest.deinit(allocator); + var latest_changed = try registry.syncActiveAccountFromAuth(allocator, codex_home, &latest); + + 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 = nowMilliseconds(); + 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; + + 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 = nowMilliseconds(); + 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 = 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; + 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); +} + +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 loadStoredSwitchSelectionDisplayWithRefreshError(allocator, codex_home, api_mode, err), + }; +} + +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, + ); + runtime.replaceLastRefreshError(loaded.refresh_error_name); + 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, + ); + runtime.replaceLastRefreshError(loaded.refresh_error_name); + 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, + ); + runtime.replaceLastRefreshError(loaded.refresh_error_name); + return .{ + .updated_display = loaded.display, + .action_message = try buildRemoveSummaryMessageAlloc(allocator, removed_labels.items), + }; +} + +pub fn resolveSwitchQueryLocally( + allocator: std.mem.Allocator, + reg: *registry.Registry, + query: []const u8, +) !SwitchQueryResolution { + if (try findAccountIndexByDisplayNumber(allocator, reg, query)) |account_idx| { + return .{ .direct = reg.accounts.items[account_idx].account_key }; + } + + var matches = try findMatchingAccounts(allocator, reg, query); + if (matches.items.len == 0) { + matches.deinit(allocator); + return .not_found; + } + if (matches.items.len == 1) { + defer matches.deinit(allocator); + return .{ .direct = reg.accounts.items[matches.items[0]].account_key }; + } + return .{ .multiple = matches }; +} + +fn handleConfig(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.ConfigOptions) !void { + switch (opts) { + .auto_switch => |auto_opts| try auto.handleAutoCommand(allocator, codex_home, auto_opts), + .api => |action| try auto.handleApiCommand(allocator, codex_home, action), + } +} + +fn freeOwnedStrings(allocator: std.mem.Allocator, items: []const []const u8) void { + for (items) |item| allocator.free(@constCast(item)); +} + +pub fn findMatchingAccounts( + allocator: std.mem.Allocator, reg: *registry.Registry, query: []const u8, ) !std.ArrayList(usize) { @@ -1726,6 +2245,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| { @@ -1847,26 +2387,88 @@ 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, + loaded.refresh_error_name, + ); + 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; + } + + 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); if (interactive_remove) { - if (usage_api_enabled) { - usage_state = refreshForegroundUsageForDisplayWithBatchFetcherAndDebugUsingApiEnabledWithBatchFailurePolicy( + if (opts.api_mode == .force_api) { + ensureForegroundNodeAvailableWithApiEnabled( allocator, codex_home, ®, - null, + .remove_account, usage_api_enabled, - true, + account_api_enabled, ) catch |err| switch (err) { error.OutOfMemory => return err, - else => null, + else => { + // Keep `remove --api` best-effort when refresh setup is unavailable. + usage_api_enabled = false; + account_api_enabled = false; + }, }; } + + usage_state = refreshForegroundUsageForDisplayWithBatchFetcherUsingApiEnabledWithBatchFailurePolicy( + allocator, + codex_home, + ®, + usage_api_enabled, + false, + ) catch |err| switch (err) { + error.OutOfMemory => return err, + else => null, + }; + maybeRefreshForegroundAccountNamesWithAccountApiEnabled( allocator, codex_home, @@ -1901,7 +2503,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) { @@ -1942,12 +2544,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; - }, - else => return err, + } + if (err == error.TuiRequiresTty) { + try cli.printRemoveRequiresTtyError(); + return error.RemoveSelectionRequiresTty; + } + return err; }; } if (selected == null) return; @@ -1960,47 +2566,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); } @@ -2067,6 +2633,32 @@ 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.?); +} + // 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 8292e49..dffdca4 100644 --- a/src/tests/cli_bdd_test.zig +++ b/src/tests/cli_bdd_test.zig @@ -166,30 +166,30 @@ 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" { +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", "--debug" }; + const args = [_][:0]const u8{ "codex-auth", "list", "--skip-api" }; 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), + .list => |opts| try std.testing.expectEqual(cli.ApiMode.skip_api, opts.api_mode), 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" { +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", "--skip-api" }; + 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.expectEqual(cli.ApiMode.skip_api, opts.api_mode), + .list => |opts| try std.testing.expect(opts.live), else => return error.TestExpectedEqual, }, else => return error.TestExpectedEqual, @@ -285,8 +285,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] [--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); try std.testing.expect(std.mem.indexOf(u8, help, "config") != null); @@ -309,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] [--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); } @@ -721,6 +721,61 @@ 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 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" }; + 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" }; @@ -730,6 +785,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" }; @@ -828,6 +892,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 +939,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" }; @@ -965,8 +1057,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)); } diff --git a/src/tests/e2e_cli_test.zig b/src/tests/e2e_cli_test.zig index 39f85e3..1e25ef2 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| @@ -1465,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); @@ -1529,7 +1491,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`, `--auto`, `--api`, or `--skip-api`") != null); } test "Scenario: Given switch query with skip-api flag when running switch then it returns a usage error" { @@ -1565,7 +1527,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`, `--auto`, `--api`, or `--skip-api`") != null); } test "Scenario: Given switch with skip-api when running interactively then it does not require api refresh executables" { @@ -1701,6 +1663,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); @@ -1778,7 +1782,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" { @@ -1814,10 +1818,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 falls back to the local picker" { const gpa = std.testing.allocator; const project_root = try projectRootAlloc(gpa); defer gpa.free(project_root); @@ -1853,13 +1857,13 @@ test "Scenario: Given interactive remove with api flag and missing refresh execu 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 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); 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.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" { 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(.{});