Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/christiandoxa_prodex.json
T

1 line
97 KiB
JSON

{"repo": "christiandoxa/prodex", "files": {"crates/prodex-app/src/quota_support/virtual_provider.rs": {"a085ce4c6c6b6ec19806845714c72109252a6f20": {"t": 1785745833.4961298, "n": 10300, "h": "7a4ddad9001407b5", "c": "use super::external_provider::fetch_agy_quota_info;\nuse super::{\n AuthSummary, ExternalQuotaDetail, ExternalQuotaInfo, ProviderQuotaSnapshot,\n QuotaProviderFilter, QuotaReport, build_upstream_blocking_http_client, format_response_body,\n quota_error_message,\n};\nuse crate::{RUNTIME_PROXY_BUFFERED_RESPONSE_MAX_BYTES, read_blocking_response_body_with_limit};\nuse anyhow::{Context, Result, bail};\nuse chrono::Local;\nuse prodex_state::ProfileProvider;\nuse std::env;\n\npub(super) fn collect_virtual_quota_reports(\n base_url: Option<&str>,\n provider_filter: QuotaProviderFilter,\n) -> Vec<QuotaReport> {\n let mut reports = Vec::new();\n if provider_filter == QuotaProviderFilter::DeepSeek {\n reports.extend(collect_deepseek_quota_reports(base_url));\n }\n if provider_filter == QuotaProviderFilter::Local {\n reports.push(collect_local_quota_report(base_url));\n }\n if provider_filter == QuotaProviderFilter::Agy {\n reports.extend(collect_agy_quota_reports());\n }\n reports\n}\n\nfn collect_deepseek_quota_reports(base_url: Option<&str>) -> Vec<QuotaReport> {\n let keys = deepseek_api_keys_from_env();\n let Some(keys) = keys else {\n return vec![virtual_quota_report(\n \"deepseek\",\n \"deepseek-key\",\n Err(\"DeepSeek quota requires DEEPSEEK_API_KEY or DEEPSEEK_API_KEYS\".to_string()),\n )];\n };\n keys.into_iter()\n .enumerate()\n .map(|(index, key)| {\n let name = if index == 0 {\n \"deepseek\".to_string()\n } else {\n format!(\"deepseek-{}\", index + 1)\n };\n let result =\n fetch_deepseek_quota_info(&key, base_url).map_err(|err| quota_error_message(&err));\n virtual_quota_report(\n &name,\n \"deepseek-key\",\n result.map(ProviderQuotaSnapshot::External),\n )\n })\n .collect()\n}\n\nfn collect_local_quota_report(base_url: Option<&str>) -> QuotaReport {\n let result = base_url\n .map(fetch_local_openai_compatible_quota_info)\n .unwrap_or_else(|| {\n Err(anyhow::anyhow!(\n \"local quota view requires --base-url pointing at the OpenAI-compatible server\"\n ))\n })\n .map(ProviderQuotaSnapshot::External)\n .map_err(|err| quota_error_message(&err));\n virtual_quota_report(\"local\", \"local\", result)\n}\n\nfn collect_agy_quota_reports() -> Vec<QuotaReport> {\n match fetch_agy_quota_info(None) {\n Ok(info) => vec![virtual_quota_report_with_provider(\n &info\n .account\n .as_deref()\n .map(|a| format!(\"agy:{a}\"))\n .unwrap_or_else(|| \"agy\".to_string()),\n \"agy\",\n Ok(ProviderQuotaSnapshot::External(info)),\n ProfileProvider::Agy { account: None },\n )],\n Err(err) => vec![virtual_quota_report_with_provider(\n \"agy\",\n \"agy\",\n Err(quota_error_message(&err)),\n ProfileProvider::Agy { account: None },\n )],\n }\n}\n\nfn virtual_quota_report(\n name: &str,\n auth_label: &str,\n result: std::result::Result<ProviderQuotaSnapshot, String>,\n) -> QuotaReport {\n virtual_quota_report_with_provider(name, auth_label, result, ProfileProvider::Openai)\n}\n\nfn virtual_quota_report_with_provider(\n name: &str,\n auth_label: &str,\n result: std::result::Result<ProviderQuotaSnapshot, String>,\n provider: ProfileProvider,\n) -> QuotaReport {\n QuotaReport {\n name: name.to_string(),\n active: false,\n auth: AuthSummary {\n label: auth_label.to_string(),\n quota_compatible: false,\n },\n provider,\n workspace_id: None,\n workspace_name: None,\n result,\n fetched_at: Local::now().timestamp(),\n }\n}\n\nfn deepseek_api_keys_from_env() -> Option<Vec<String>> {\n env::var(\"DEEPSEEK_API_KEYS\")\n .ok()\n .and_then(|value| provider_api_keys_from_list(&value))\n .or_else(|| {\n env::var(\"DEEPSEEK_API_KEY\")\n .ok()\n .map(|value| value.trim().to_string())\n .filter(|value| !value.is_empty())\n .map(|value| vec![value])\n })\n}\n\nfn provider_api_keys_from_list(value: &str) -> Option<Vec<String>> {\n let keys = value\n .split([',', ';', '\\n'])\n .map(str::trim)\n .filter(|value| !value.is_empty())\n .map(str::to_string)\n .collect::<Vec<_>>();\n (!keys.is_empty()).then_some(keys)\n}\n\nfn fetch_deepseek_quota_info(api_key: &str, base_url: Option<&str>) -> Result<ExternalQuotaInfo> {\n let value = fetch_deepseek_balance_json(api_key, base_url)?;\n let available = value\n .get(\"is_available\")\n .and_then(serde_json::Value::as_bool)\n .unwrap_or(false);\n let balances = value\n .get(\"balance_infos\")\n .and_then(serde_json::Value::as_array)\n .cloned()\n .unwrap_or_default();\n let mut details = Vec::new();\n let mut parts = Vec::new();\n for balance in balances {\n let currency = balance\n .get(\"currency\")\n .and_then(serde_json::Value::as_str)\n .unwrap_or(\"balance\");\n let total = balance\n .get(\"total_balance\")\n .and_then(serde_json::Value::as_str)\n .unwrap_or(\"-\");\n parts.push(format!(\"{currency} {total}\"));\n let granted = balance\n .get(\"granted_balance\")\n .and_then(serde_json::Value::as_str)\n .unwrap_or(\"-\");\n let topped_up = balance\n .get(\"topped_up_balance\")\n .and_then(serde_json::Value::as_str)\n .unwrap_or(\"-\");\n details.push(ExternalQuotaDetail {\n label: format!(\"{currency} balance\"),\n value: format!(\"total {total}; granted {granted}; topped up {topped_up}\"),\n });\n }\n Ok(ExternalQuotaInfo {\n provider: \"DeepSeek\".to_string(),\n account: None,\n plan: Some(\"api-key\".to_string()),\n status: if available { \"Ready\" } else { \"Blocked\" }.to_string(),\n main: if parts.is_empty() {\n \"balance unavailable\".to_string()\n } else {\n parts.join(\" | \")\n },\n reset: None,\n available: Some(available),\n details,\n })\n}\n\nfn fetch_deepseek_balance_json(api_key: &str, base_url: Option<&str>) -> Result<serde_json::Value> {\n let client = build_upstream_blocking_http_client(\"DeepSeek quota HTTP\", false)?;\n let url = format!(\"{}/user/balance\", deepseek_base_url(base_url));\n let response = client\n .get(&url)\n .header(\"accept\", \"application/json\")\n .bearer_auth(api_key)\n .send()\n .context(\"failed to fetch DeepSeek balance\")?;\n let status = response.status();\n let body = read_blocking_response_body_with_limit(\n response,\n RUNTIME_PROXY_BUFFERED_RESPONSE_MAX_BYTES,\n \"failed to read DeepSeek balance response\",\n )?;\n if !status.is_success() {\n bail!(\n \"DeepSeek balance request failed (HTTP {}): {}\",\n status.as_u16(),\n format_response_body(&body)\n );\n }\n serde_json::from_slice(&body).context(\"failed to parse DeepSeek balance response\")\n}\n\nfn deepseek_base_url(explicit: Option<&str>) -> String {\n let base = explicit\n .map(str::to_string)\n .or_else(|| env::var(\"PRODEX_DEEPSEEK_BASE_URL\").ok())\n .or_else(|| env::var(\"DEEPSEEK_BASE_URL\").ok())\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n base.trim()\n .trim_end_matches('/')\n .trim_end_matches(\"/v1\")\n .to_string()\n}\n\nfn fetch_local_openai_compatible_quota_info(base_url: &str) -> Result<ExternalQuotaInfo> {\n let client = build_upstream_blocking_http_client(\"local OpenAI-compatible quota HTTP\", false)?;\n let url = openai_compatible_models_url(base_url)?;\n let mut request = client.get(&url).header(\"accept\", \"application/json\");\n if let Some(api_key) = local_openai_compatible_api_key_from_env() {\n request = request.bearer_auth(api_key);\n }\n let response = request\n .send()\n .context(\"failed to fetch local OpenAI-compatible models\")?;\n let status = response.status();\n let body = read_blocking_response_body_with_limit(\n response,\n RUNTIME_PROXY_BUFFERED_RESPONSE_MAX_BYTES,\n \"failed to read local OpenAI-compatible response\",\n )?;\n if !status.is_success() {\n bail!(\n \"local OpenAI-compatible models request failed (HTTP {}): {}\",\n status.as_u16(),\n format_response_body(&body)\n );\n }\n let value: serde_json::Value =\n serde_json::from_slice(&body).context(\"failed to parse local models response\")?;\n let model_count = value\n .get(\"data\")\n .and_then(serde_json::Value::as_array)\n .map(Vec::len);\n Ok(ExternalQuotaInfo {\n provider: \"Local OpenAI-compatible\".to_string(),\n account: Some(base_url.trim().to_string()),\n plan: Some(\"local\".to_string()),\n status: \"Reachable\".to_string(),\n main: model_count.map_or_else(\n || \"models endpoint reachable\".to_string(),\n |count| format!(\"{count} model(s)\"),\n ),\n reset: None,\n available: Some(true),\n details: vec![ExternalQuotaDetail {\n label: \"Models URL\".to_string(),\n value: url,\n }],\n })\n}\n\nfn openai_compatible_models_url(base_url: &str) -> Result<String> {\n let mut url = reqwest::Url::parse(base_url.trim())\n .with_context(|| format!(\"invalid local base URL '{}'\", base_url.trim()))?;\n let path = url.path().trim_end_matches('/');\n if path.is_empty() {\n url.set_path(\"/v1/models\");\n } else if !path.ends_with(\"/models\") {\n url.set_path(&format!(\"{path}/models\"));\n }\n Ok(url.to_string())\n}\n\nfn local_openai_compatible_api_key_from_env() -> Option<String> {\n [\"PRODEX_LOCAL_API_KEY\", \"OPENAI_API_KEY\"]\n .into_iter()\n .find_map(|key| {\n env::var(key)\n .ok()\n .map(|value| value.trim().to_string())\n .filter(|value| !value.is_empty())\n })\n}\n"}, "5b074ea421df906052c08d2bf2c94c7476836cba": {"t": 1785757005.225967, "n": 10300, "h": "7a4ddad9001407b5", "c": "use super::external_provider::fetch_agy_quota_info;\nuse super::{\n AuthSummary, ExternalQuotaDetail, ExternalQuotaInfo, ProviderQuotaSnapshot,\n QuotaProviderFilter, QuotaReport, build_upstream_blocking_http_client, format_response_body,\n quota_error_message,\n};\nuse crate::{RUNTIME_PROXY_BUFFERED_RESPONSE_MAX_BYTES, read_blocking_response_body_with_limit};\nuse anyhow::{Context, Result, bail};\nuse chrono::Local;\nuse prodex_state::ProfileProvider;\nuse std::env;\n\npub(super) fn collect_virtual_quota_reports(\n base_url: Option<&str>,\n provider_filter: QuotaProviderFilter,\n) -> Vec<QuotaReport> {\n let mut reports = Vec::new();\n if provider_filter == QuotaProviderFilter::DeepSeek {\n reports.extend(collect_deepseek_quota_reports(base_url));\n }\n if provider_filter == QuotaProviderFilter::Local {\n reports.push(collect_local_quota_report(base_url));\n }\n if provider_filter == QuotaProviderFilter::Agy {\n reports.extend(collect_agy_quota_reports());\n }\n reports\n}\n\nfn collect_deepseek_quota_reports(base_url: Option<&str>) -> Vec<QuotaReport> {\n let keys = deepseek_api_keys_from_env();\n let Some(keys) = keys else {\n return vec![virtual_quota_report(\n \"deepseek\",\n \"deepseek-key\",\n Err(\"DeepSeek quota requires DEEPSEEK_API_KEY or DEEPSEEK_API_KEYS\".to_string()),\n )];\n };\n keys.into_iter()\n .enumerate()\n .map(|(index, key)| {\n let name = if index == 0 {\n \"deepseek\".to_string()\n } else {\n format!(\"deepseek-{}\", index + 1)\n };\n let result =\n fetch_deepseek_quota_info(&key, base_url).map_err(|err| quota_error_message(&err));\n virtual_quota_report(\n &name,\n \"deepseek-key\",\n result.map(ProviderQuotaSnapshot::External),\n )\n })\n .collect()\n}\n\nfn collect_local_quota_report(base_url: Option<&str>) -> QuotaReport {\n let result = base_url\n .map(fetch_local_openai_compatible_quota_info)\n .unwrap_or_else(|| {\n Err(anyhow::anyhow!(\n \"local quota view requires --base-url pointing at the OpenAI-compatible server\"\n ))\n })\n .map(ProviderQuotaSnapshot::External)\n .map_err(|err| quota_error_message(&err));\n virtual_quota_report(\"local\", \"local\", result)\n}\n\nfn collect_agy_quota_reports() -> Vec<QuotaReport> {\n match fetch_agy_quota_info(None) {\n Ok(info) => vec![virtual_quota_report_with_provider(\n &info\n .account\n .as_deref()\n .map(|a| format!(\"agy:{a}\"))\n .unwrap_or_else(|| \"agy\".to_string()),\n \"agy\",\n Ok(ProviderQuotaSnapshot::External(info)),\n ProfileProvider::Agy { account: None },\n )],\n Err(err) => vec![virtual_quota_report_with_provider(\n \"agy\",\n \"agy\",\n Err(quota_error_message(&err)),\n ProfileProvider::Agy { account: None },\n )],\n }\n}\n\nfn virtual_quota_report(\n name: &str,\n auth_label: &str,\n result: std::result::Result<ProviderQuotaSnapshot, String>,\n) -> QuotaReport {\n virtual_quota_report_with_provider(name, auth_label, result, ProfileProvider::Openai)\n}\n\nfn virtual_quota_report_with_provider(\n name: &str,\n auth_label: &str,\n result: std::result::Result<ProviderQuotaSnapshot, String>,\n provider: ProfileProvider,\n) -> QuotaReport {\n QuotaReport {\n name: name.to_string(),\n active: false,\n auth: AuthSummary {\n label: auth_label.to_string(),\n quota_compatible: false,\n },\n provider,\n workspace_id: None,\n workspace_name: None,\n result,\n fetched_at: Local::now().timestamp(),\n }\n}\n\nfn deepseek_api_keys_from_env() -> Option<Vec<String>> {\n env::var(\"DEEPSEEK_API_KEYS\")\n .ok()\n .and_then(|value| provider_api_keys_from_list(&value))\n .or_else(|| {\n env::var(\"DEEPSEEK_API_KEY\")\n .ok()\n .map(|value| value.trim().to_string())\n .filter(|value| !value.is_empty())\n .map(|value| vec![value])\n })\n}\n\nfn provider_api_keys_from_list(value: &str) -> Option<Vec<String>> {\n let keys = value\n .split([',', ';', '\\n'])\n .map(str::trim)\n .filter(|value| !value.is_empty())\n .map(str::to_string)\n .collect::<Vec<_>>();\n (!keys.is_empty()).then_some(keys)\n}\n\nfn fetch_deepseek_quota_info(api_key: &str, base_url: Option<&str>) -> Result<ExternalQuotaInfo> {\n let value = fetch_deepseek_balance_json(api_key, base_url)?;\n let available = value\n .get(\"is_available\")\n .and_then(serde_json::Value::as_bool)\n .unwrap_or(false);\n let balances = value\n .get(\"balance_infos\")\n .and_then(serde_json::Value::as_array)\n .cloned()\n .unwrap_or_default();\n let mut details = Vec::new();\n let mut parts = Vec::new();\n for balance in balances {\n let currency = balance\n .get(\"currency\")\n .and_then(serde_json::Value::as_str)\n .unwrap_or(\"balance\");\n let total = balance\n .get(\"total_balance\")\n .and_then(serde_json::Value::as_str)\n .unwrap_or(\"-\");\n parts.push(format!(\"{currency} {total}\"));\n let granted = balance\n .get(\"granted_balance\")\n .and_then(serde_json::Value::as_str)\n .unwrap_or(\"-\");\n let topped_up = balance\n .get(\"topped_up_balance\")\n .and_then(serde_json::Value::as_str)\n .unwrap_or(\"-\");\n details.push(ExternalQuotaDetail {\n label: format!(\"{currency} balance\"),\n value: format!(\"total {total}; granted {granted}; topped up {topped_up}\"),\n });\n }\n Ok(ExternalQuotaInfo {\n provider: \"DeepSeek\".to_string(),\n account: None,\n plan: Some(\"api-key\".to_string()),\n status: if available { \"Ready\" } else { \"Blocked\" }.to_string(),\n main: if parts.is_empty() {\n \"balance unavailable\".to_string()\n } else {\n parts.join(\" | \")\n },\n reset: None,\n available: Some(available),\n details,\n })\n}\n\nfn fetch_deepseek_balance_json(api_key: &str, base_url: Option<&str>) -> Result<serde_json::Value> {\n let client = build_upstream_blocking_http_client(\"DeepSeek quota HTTP\", false)?;\n let url = format!(\"{}/user/balance\", deepseek_base_url(base_url));\n let response = client\n .get(&url)\n .header(\"accept\", \"application/json\")\n .bearer_auth(api_key)\n .send()\n .context(\"failed to fetch DeepSeek balance\")?;\n let status = response.status();\n let body = read_blocking_response_body_with_limit(\n response,\n RUNTIME_PROXY_BUFFERED_RESPONSE_MAX_BYTES,\n \"failed to read DeepSeek balance response\",\n )?;\n if !status.is_success() {\n bail!(\n \"DeepSeek balance request failed (HTTP {}): {}\",\n status.as_u16(),\n format_response_body(&body)\n );\n }\n serde_json::from_slice(&body).context(\"failed to parse DeepSeek balance response\")\n}\n\nfn deepseek_base_url(explicit: Option<&str>) -> String {\n let base = explicit\n .map(str::to_string)\n .or_else(|| env::var(\"PRODEX_DEEPSEEK_BASE_URL\").ok())\n .or_else(|| env::var(\"DEEPSEEK_BASE_URL\").ok())\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n base.trim()\n .trim_end_matches('/')\n .trim_end_matches(\"/v1\")\n .to_string()\n}\n\nfn fetch_local_openai_compatible_quota_info(base_url: &str) -> Result<ExternalQuotaInfo> {\n let client = build_upstream_blocking_http_client(\"local OpenAI-compatible quota HTTP\", false)?;\n let url = openai_compatible_models_url(base_url)?;\n let mut request = client.get(&url).header(\"accept\", \"application/json\");\n if let Some(api_key) = local_openai_compatible_api_key_from_env() {\n request = request.bearer_auth(api_key);\n }\n let response = request\n .send()\n .context(\"failed to fetch local OpenAI-compatible models\")?;\n let status = response.status();\n let body = read_blocking_response_body_with_limit(\n response,\n RUNTIME_PROXY_BUFFERED_RESPONSE_MAX_BYTES,\n \"failed to read local OpenAI-compatible response\",\n )?;\n if !status.is_success() {\n bail!(\n \"local OpenAI-compatible models request failed (HTTP {}): {}\",\n status.as_u16(),\n format_response_body(&body)\n );\n }\n let value: serde_json::Value =\n serde_json::from_slice(&body).context(\"failed to parse local models response\")?;\n let model_count = value\n .get(\"data\")\n .and_then(serde_json::Value::as_array)\n .map(Vec::len);\n Ok(ExternalQuotaInfo {\n provider: \"Local OpenAI-compatible\".to_string(),\n account: Some(base_url.trim().to_string()),\n plan: Some(\"local\".to_string()),\n status: \"Reachable\".to_string(),\n main: model_count.map_or_else(\n || \"models endpoint reachable\".to_string(),\n |count| format!(\"{count} model(s)\"),\n ),\n reset: None,\n available: Some(true),\n details: vec![ExternalQuotaDetail {\n label: \"Models URL\".to_string(),\n value: url,\n }],\n })\n}\n\nfn openai_compatible_models_url(base_url: &str) -> Result<String> {\n let mut url = reqwest::Url::parse(base_url.trim())\n .with_context(|| format!(\"invalid local base URL '{}'\", base_url.trim()))?;\n let path = url.path().trim_end_matches('/');\n if path.is_empty() {\n url.set_path(\"/v1/models\");\n } else if !path.ends_with(\"/models\") {\n url.set_path(&format!(\"{path}/models\"));\n }\n Ok(url.to_string())\n}\n\nfn local_openai_compatible_api_key_from_env() -> Option<String> {\n [\"PRODEX_LOCAL_API_KEY\", \"OPENAI_API_KEY\"]\n .into_iter()\n .find_map(|key| {\n env::var(key)\n .ok()\n .map(|value| value.trim().to_string())\n .filter(|value| !value.is_empty())\n })\n}\n"}}, "crates/prodex-app/src/profile_commands/login/login_menu.rs": {"a085ce4c6c6b6ec19806845714c72109252a6f20": {"t": 1785745878.9782174, "n": 18676, "h": "0ac5d1c74c42cd8b", "c": "use super::LoginMethod;\nuse anyhow::{Context, Result, bail};\nuse crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};\nuse ratatui::layout::{Constraint, Direction, Layout};\nuse ratatui::style::Modifier;\nuse ratatui::text::{Line, Span, Text};\nuse ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};\nuse std::io::{self, IsTerminal, Write};\nuse terminal_ui::{\n tui_border_style, tui_connected_footer_block, tui_connected_header_block, tui_detail_style,\n tui_error_style, tui_hint_style, tui_primary_style, tui_secondary_style, tui_success_style,\n tui_title_style,\n};\n\nconst LOGIN_MENU_MIN_VISIBLE_ITEMS: usize = 3;\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub(super) enum LoginMenuAction {\n Method(LoginMethod),\n Guidance(LoginGuidanceKind),\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub(super) enum LoginGuidanceKind {\n GeminiApiKey,\n AnthropicApiKey,\n DeepSeekApiKey,\n CopilotImport,\n}\n\n#[derive(Debug, Clone, Copy)]\nstruct LoginMenuEntry {\n title: &'static str,\n provider: &'static str,\n auth: &'static str,\n usage: &'static str,\n command: &'static str,\n action: LoginMenuAction,\n}\n\n#[derive(Debug, Clone, Copy)]\nstruct LoginMenuLayout {\n visible_items: usize,\n compact: bool,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum LoginMenuKey {\n Up,\n Down,\n PageUp,\n PageDown,\n Home,\n End,\n Enter,\n Cancel,\n Digit(usize),\n Ignore,\n}\n\ntype LoginMenuTui = terminal_ui::AlternateScreenTerminal<io::Stderr>;\n\npub(super) fn login_prompt_is_interactive() -> bool {\n io::stdin().is_terminal() && io::stderr().is_terminal()\n}\n\nfn login_menu_entries() -> &'static [LoginMenuEntry] {\n const ENTRIES: &[LoginMenuEntry] = &[\n LoginMenuEntry {\n title: \"Sign in with ChatGPT (OpenAI OAuth)\",\n provider: \"OpenAI / Codex\",\n auth: \"ChatGPT OAuth\",\n usage: \"Default quota-aware profile pool for prodex run, prodex s, and prodex caveman.\",\n command: \"prodex login\",\n action: LoginMenuAction::Method(LoginMethod::ChatGpt),\n },\n LoginMenuEntry {\n title: \"OpenAI device code\",\n provider: \"OpenAI / Codex\",\n auth: \"Device-code OAuth\",\n usage: \"Same OpenAI/Codex profile type, useful on a terminal without a local browser.\",\n command: \"prodex login --device-auth\",\n action: LoginMenuAction::Method(LoginMethod::DeviceCode),\n },\n LoginMenuEntry {\n title: \"Provide your own API key (OpenAI/API-compatible)\",\n provider: \"OpenAI / local / OpenAI-compatible endpoint\",\n auth: \"API key stored in the selected Prodex profile\",\n usage: \"Use OpenAI API billing, or provide a compatible base URL for local/custom endpoints.\",\n command: \"prodex login --with-api-key [--base-url URL]\",\n action: LoginMenuAction::Method(LoginMethod::ApiKey),\n },\n LoginMenuEntry {\n title: \"Google Gemini OAuth\",\n provider: \"Google Gemini\",\n auth: \"Google OAuth profile\",\n usage: \"Reusable Gemini profile for prodex s --provider gemini, including OAuth rotation.\",\n command: \"prodex login --with-google\",\n action: LoginMenuAction::Method(LoginMethod::Google),\n },\n LoginMenuEntry {\n title: \"Google Gemini API key\",\n provider: \"Google Gemini\",\n auth: \"Runtime API key only\",\n usage: \"Not a persisted login profile; pass GEMINI_API_KEY, GEMINI_API_KEYS, GOOGLE_API_KEY(S), or --api-key when launching Gemini.\",\n command: \"GEMINI_API_KEY=... prodex s --provider gemini --model gemini-2.5-pro\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::GeminiApiKey),\n },\n LoginMenuEntry {\n title: \"Anthropic Claude OAuth\",\n provider: \"Anthropic Claude\",\n auth: \"Claude Code OAuth profile\",\n usage: \"Reusable Anthropic profile for prodex s --provider anthropic without API-key storage.\",\n command: \"prodex login --with-claude\",\n action: LoginMenuAction::Method(LoginMethod::Claude),\n },\n LoginMenuEntry {\n title: \"Google Antigravity CLI\",\n provider: \"Google Antigravity\",\n auth: \"Antigravity CLI keyring / Google Sign-In\",\n usage: \"Authenticate the native agy CLI used by prodex s gemini --cli agy.\",\n command: \"prodex login --with-antigravity\",\n action: LoginMenuAction::Method(LoginMethod::Antigravity),\n },\n LoginMenuEntry {\n title: \"Anthropic API key\",\n provider: \"Anthropic Claude\",\n auth: \"Runtime API key only\",\n usage: \"Not a persisted login profile; pass ANTHROPIC_API_KEY(S) or --api-key.\",\n command: \"ANTHROPIC_API_KEY=... prodex s --provider anthropic --model claude-sonnet-4-6\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::AnthropicApiKey),\n },\n LoginMenuEntry {\n title: \"DeepSeek API key\",\n provider: \"DeepSeek\",\n auth: \"Runtime API key only\",\n usage: \"DeepSeek has no OAuth login in Prodex; use an API key for the provider adapter.\",\n command: \"DEEPSEEK_API_KEY=... prodex s --provider deepseek --model deepseek-v4-pro\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::DeepSeekApiKey),\n },\n LoginMenuEntry {\n title: \"GitHub Copilot import\",\n provider: \"GitHub Copilot\",\n auth: \"Existing Copilot CLI account import\",\n usage: \"Record the Copilot identity from local Copilot CLI state, then launch with --provider copilot.\",\n command: \"prodex profile import copilot\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::CopilotImport),\n },\n ];\n ENTRIES\n}\n\npub(super) fn prompt_login_menu_action() -> Result<LoginMenuAction> {\n if let Some(action) = prompt_login_menu_action_raw()? {\n return Ok(action);\n }\n prompt_login_menu_action_numbered()\n}\n\nfn prompt_login_menu_action_raw() -> Result<Option<LoginMenuAction>> {\n let entries = login_menu_entries();\n let mut tui = match LoginMenuTui::stderr(\"login TUI\") {\n Ok(tui) => tui,\n Err(_) => return Ok(None),\n };\n let mut selected = 0usize;\n let mut offset = 0usize;\n\n loop {\n let size = tui\n .terminal\n .size()\n .context(\"failed to read login TUI terminal size\")?;\n let layout = login_menu_layout_for_rows(usize::from(size.height), entries.len());\n offset = login_menu_window_offset(selected, offset, layout.visible_items, entries.len());\n tui.terminal\n .draw(|frame| render_login_menu_tui(frame, entries, selected, offset, layout))\n .context(\"failed to draw login TUI\")?;\n if let Some(action) = apply_login_menu_key(\n read_login_menu_key()?,\n &mut selected,\n layout.visible_items,\n entries,\n )? {\n return Ok(Some(action));\n }\n }\n}\n\nfn read_login_menu_key() -> Result<LoginMenuKey> {\n loop {\n match event::read().context(\"failed to read login TUI input\")? {\n Event::Key(key) if key.kind == KeyEventKind::Press => {\n return Ok(login_menu_key_from_event(key));\n }\n Event::Resize(_, _) => return Ok(LoginMenuKey::Ignore),\n _ => {}\n }\n }\n}\n\nfn apply_login_menu_key(\n key: LoginMenuKey,\n selected: &mut usize,\n visible_items: usize,\n entries: &[LoginMenuEntry],\n) -> Result<Option<LoginMenuAction>> {\n let last = entries.len().saturating_sub(1);\n match key {\n LoginMenuKey::Up => *selected = selected.saturating_sub(1),\n LoginMenuKey::Down => *selected = (*selected + 1).min(last),\n LoginMenuKey::PageUp => {\n *selected = selected.saturating_sub(login_menu_page_step(visible_items))\n }\n LoginMenuKey::PageDown => {\n *selected = (*selected + login_menu_page_step(visible_items)).min(last)\n }\n LoginMenuKey::Home => *selected = 0,\n LoginMenuKey::End => *selected = last,\n LoginMenuKey::Enter => return Ok(Some(entries[*selected].action)),\n LoginMenuKey::Digit(index) => {\n return Ok(entries\n .get(index.saturating_sub(1))\n .map(|entry| entry.action));\n }\n LoginMenuKey::Cancel => bail!(\"login cancelled\"),\n LoginMenuKey::Ignore => {}\n }\n Ok(None)\n}\n\nfn login_menu_page_step(visible_items: usize) -> usize {\n visible_items.saturating_sub(1).max(1)\n}\n\nfn login_menu_key_from_event(key: KeyEvent) -> LoginMenuKey {\n match key.code {\n KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('K') => LoginMenuKey::Up,\n KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('J') => LoginMenuKey::Down,\n KeyCode::PageUp | KeyCode::Char('u') | KeyCode::Char('U') => LoginMenuKey::PageUp,\n KeyCode::PageDown | KeyCode::Char('d') | KeyCode::Char('D') => LoginMenuKey::PageDown,\n KeyCode::Home | KeyCode::Char('g') => LoginMenuKey::Home,\n KeyCode::End | KeyCode::Char('G') => LoginMenuKey::End,\n KeyCode::Enter => LoginMenuKey::Enter,\n KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => LoginMenuKey::Cancel,\n KeyCode::Char('c') | KeyCode::Char('z')\n if key.modifiers.contains(KeyModifiers::CONTROL) =>\n {\n LoginMenuKey::Cancel\n }\n KeyCode::Char(ch) if ('1'..='9').contains(&ch) => {\n LoginMenuKey::Digit(ch.to_digit(10).unwrap_or(0) as usize)\n }\n _ => LoginMenuKey::Ignore,\n }\n}\n\nfn prompt_login_menu_action_numbered() -> Result<LoginMenuAction> {\n let entries = login_menu_entries();\n let mut stderr = io::stderr();\n writeln!(stderr, \"Choose login method:\")?;\n for (index, entry) in entries.iter().enumerate() {\n writeln!(stderr, \" {}. {}\", index + 1, entry.title)?;\n writeln!(stderr, \" Provider: {}\", entry.provider)?;\n writeln!(stderr, \" Auth: {}\", entry.auth)?;\n writeln!(stderr, \" Use: {}\", entry.usage)?;\n writeln!(stderr, \" Command: {}\", entry.command)?;\n }\n loop {\n write!(stderr, \"Select login method [1]: \")?;\n stderr.flush()?;\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read login method\")?;\n let selected = input.trim();\n if selected.is_empty() {\n return Ok(entries[0].action);\n }\n if let Ok(index) = selected.parse::<usize>()\n && (1..=entries.len()).contains(&index)\n {\n return Ok(entries[index - 1].action);\n }\n writeln!(stderr, \"Enter 1 through {}.\", entries.len())?;\n }\n}\n\npub(super) fn show_login_guidance(kind: LoginGuidanceKind) -> Result<()> {\n let entry = login_menu_entries()\n .iter()\n .find(|entry| entry.action == LoginMenuAction::Guidance(kind))\n .context(\"login guidance entry is missing\")?;\n let mut stderr = io::stderr();\n writeln!(stderr)?;\n writeln!(stderr, \"Provider guidance: {}\", entry.title)?;\n writeln!(stderr, \" Provider: {}\", entry.provider)?;\n writeln!(stderr, \" Auth: {}\", entry.auth)?;\n writeln!(stderr, \" Use: {}\", entry.usage)?;\n writeln!(stderr, \" Command: {}\", entry.command)?;\n writeln!(\n stderr,\n \" Note: this path is selected at runtime, not stored by prodex login.\"\n )?;\n writeln!(stderr)?;\n write!(\n stderr,\n \"Press Enter to return to login methods, or Ctrl-C to exit.\"\n )?;\n stderr.flush()?;\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read login guidance acknowledgement\")?;\n Ok(())\n}\n\nfn login_menu_layout_for_rows(rows: usize, entry_count: usize) -> LoginMenuLayout {\n if entry_count == 0 {\n return LoginMenuLayout {\n visible_items: 0,\n compact: true,\n };\n }\n\n let rows = rows.max(8);\n let compact = rows < 16;\n let reserved_rows = if compact { 5 } else { 7 };\n let min_visible = LOGIN_MENU_MIN_VISIBLE_ITEMS.min(entry_count);\n let visible_items = rows\n .saturating_sub(reserved_rows)\n .max(min_visible)\n .min(entry_count);\n LoginMenuLayout {\n visible_items,\n compact,\n }\n}\n\nfn login_menu_window_offset(\n selected: usize,\n current_offset: usize,\n visible_items: usize,\n entry_count: usize,\n) -> usize {\n if entry_count == 0 || visible_items == 0 {\n return 0;\n }\n let max_offset = entry_count.saturating_sub(visible_items);\n if selected < current_offset {\n selected.min(max_offset)\n } else if selected >= current_offset.saturating_add(visible_items) {\n selected\n .saturating_add(1)\n .saturating_sub(visible_items)\n .min(max_offset)\n } else {\n current_offset.min(max_offset)\n }\n}\n\nfn render_login_menu_tui(\n frame: &mut ratatui::Frame<'_>,\n entries: &[LoginMenuEntry],\n selected: usize,\n offset: usize,\n layout: LoginMenuLayout,\n) {\n let area = frame.area();\n let chunks = Layout::default()\n .direction(Direction::Vertical)\n .constraints([\n Constraint::Length(3),\n Constraint::Min(4),\n Constraint::Length(if layout.compact { 5 } else { 7 }),\n ])\n .split(area);\n\n let visible_items = layout.visible_items.max(1).min(entries.len().max(1));\n let end = offset.saturating_add(visible_items).min(entries.len());\n render_login_menu_header(frame, chunks[0], offset, end, entries.len());\n let items = login_menu_items(entries, selected, offset, end);\n let list = List::new(items).block(\n Block::default()\n .borders(Borders::LEFT | Borders::RIGHT)\n .border_style(tui_border_style()),\n );\n frame.render_widget(list, chunks[1]);\n render_login_menu_detail(frame, chunks[2], entries, selected, layout);\n}\n\nfn render_login_menu_header(\n frame: &mut ratatui::Frame<'_>,\n area: ratatui::layout::Rect,\n offset: usize,\n end: usize,\n entry_count: usize,\n) {\n let header = Paragraph::new(Line::from(vec![\n Span::styled(\"Prodex Login\", tui_title_style()),\n Span::raw(\" \"),\n Span::styled(\n format!(\n \"methods {}-{} of {}\",\n offset.saturating_add(1).min(entry_count),\n end,\n entry_count\n ),\n tui_secondary_style(),\n ),\n ]))\n .block(tui_connected_header_block(tui_border_style()));\n frame.render_widget(header, area);\n}\n\nfn login_menu_items(\n entries: &[LoginMenuEntry],\n selected: usize,\n offset: usize,\n end: usize,\n) -> Vec<ListItem<'static>> {\n if entries.is_empty() {\n return vec![ListItem::new(Line::styled(\n \"No login methods available\",\n tui_error_style(),\n ))];\n }\n entries\n .iter()\n .enumerate()\n .take(end)\n .skip(offset)\n .map(|(index, entry)| {\n let marker = if index == selected {\n \">\"\n } else if index == offset && offset > 0 {\n \"^\"\n } else if index + 1 == end && end < entries.len() {\n \"v\"\n } else {\n \" \"\n };\n let style = if index == selected {\n tui_hint_style().add_modifier(Modifier::BOLD)\n } else {\n tui_primary_style()\n };\n ListItem::new(Line::from(vec![\n Span::styled(format!(\"{marker} {:>2}. \", index + 1), style),\n Span::styled(entry.title, style),\n Span::raw(\" \"),\n Span::styled(format!(\"[{}]\", entry.auth), tui_hint_style()),\n ]))\n })\n .collect()\n}\n\nfn render_login_menu_detail(\n frame: &mut ratatui::Frame<'_>,\n area: ratatui::layout::Rect,\n entries: &[LoginMenuEntry],\n selected: usize,\n layout: LoginMenuLayout,\n) {\n let detail = if entries.is_empty() {\n Text::from(Line::styled(\n \"No login methods are registered.\",\n tui_error_style(),\n ))\n } else {\n login_menu_detail_text(\n &entries[selected.min(entries.len().saturating_sub(1))],\n layout,\n )\n };\n let detail = Paragraph::new(detail)\n .block(tui_connected_footer_block(tui_border_style()))\n .wrap(ratatui::widgets::Wrap { trim: false });\n frame.render_widget(detail, area);\n}\n\nfn login_menu_detail_text(entry: &LoginMenuEntry, layout: LoginMenuLayout) -> Text<'static> {\n if layout.compact {\n return Text::from(vec![\n Line::from(vec![\n Span::styled(\"Select login method \", tui_secondary_style()),\n Span::styled(\"Provider \", tui_secondary_style()),\n Span::styled(entry.provider, tui_success_style()),\n Span::raw(\" | \"),\n Span::styled(entry.auth, tui_hint_style()),\n ]),\n Line::from(vec![\n Span::styled(\"Use \", tui_secondary_style()),\n Span::raw(entry.usage),\n ]),\n Line::from(vec![\n Span::styled(\"Cmd \", tui_secondary_style()),\n Span::styled(entry.command, tui_hint_style()),\n ]),\n ]);\n }\n\n Text::from(vec![\n Line::from(vec![\n Span::styled(\"Select login method \", tui_secondary_style()),\n Span::styled(\"Provider \", tui_secondary_style()),\n Span::styled(entry.provider, tui_success_style()),\n ]),\n Line::from(vec![\n Span::styled(\"Auth \", tui_secondary_style()),\n Span::styled(entry.auth, tui_hint_style()),\n ]),\n Line::from(vec![\n Span::styled(\"Use \", tui_secondary_style()),\n Span::raw(entry.usage),\n ]),\n Line::from(vec![\n Span::styled(\"Command \", tui_secondary_style()),\n Span::styled(entry.command, tui_hint_style()),\n ]),\n Line::from(Span::styled(\n \"Select login method | Up/Down move | PageUp/PageDown scroll | Enter select | 1-9 quick select | q cancel\",\n tui_detail_style().add_modifier(Modifier::ITALIC),\n )),\n ])\n}\n\n#[cfg(test)]\n#[path = \"../../../tests/src/profile_commands/login_menu.rs\"]\nmod login_menu_tests;\n"}, "d3b8546692d1a5ac582be033258c643dc1218913": {"t": 1785757000.5438979, "n": 18676, "h": "0ac5d1c74c42cd8b", "c": "use super::LoginMethod;\nuse anyhow::{Context, Result, bail};\nuse crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};\nuse ratatui::layout::{Constraint, Direction, Layout};\nuse ratatui::style::Modifier;\nuse ratatui::text::{Line, Span, Text};\nuse ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};\nuse std::io::{self, IsTerminal, Write};\nuse terminal_ui::{\n tui_border_style, tui_connected_footer_block, tui_connected_header_block, tui_detail_style,\n tui_error_style, tui_hint_style, tui_primary_style, tui_secondary_style, tui_success_style,\n tui_title_style,\n};\n\nconst LOGIN_MENU_MIN_VISIBLE_ITEMS: usize = 3;\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub(super) enum LoginMenuAction {\n Method(LoginMethod),\n Guidance(LoginGuidanceKind),\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub(super) enum LoginGuidanceKind {\n GeminiApiKey,\n AnthropicApiKey,\n DeepSeekApiKey,\n CopilotImport,\n}\n\n#[derive(Debug, Clone, Copy)]\nstruct LoginMenuEntry {\n title: &'static str,\n provider: &'static str,\n auth: &'static str,\n usage: &'static str,\n command: &'static str,\n action: LoginMenuAction,\n}\n\n#[derive(Debug, Clone, Copy)]\nstruct LoginMenuLayout {\n visible_items: usize,\n compact: bool,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum LoginMenuKey {\n Up,\n Down,\n PageUp,\n PageDown,\n Home,\n End,\n Enter,\n Cancel,\n Digit(usize),\n Ignore,\n}\n\ntype LoginMenuTui = terminal_ui::AlternateScreenTerminal<io::Stderr>;\n\npub(super) fn login_prompt_is_interactive() -> bool {\n io::stdin().is_terminal() && io::stderr().is_terminal()\n}\n\nfn login_menu_entries() -> &'static [LoginMenuEntry] {\n const ENTRIES: &[LoginMenuEntry] = &[\n LoginMenuEntry {\n title: \"Sign in with ChatGPT (OpenAI OAuth)\",\n provider: \"OpenAI / Codex\",\n auth: \"ChatGPT OAuth\",\n usage: \"Default quota-aware profile pool for prodex run, prodex s, and prodex caveman.\",\n command: \"prodex login\",\n action: LoginMenuAction::Method(LoginMethod::ChatGpt),\n },\n LoginMenuEntry {\n title: \"OpenAI device code\",\n provider: \"OpenAI / Codex\",\n auth: \"Device-code OAuth\",\n usage: \"Same OpenAI/Codex profile type, useful on a terminal without a local browser.\",\n command: \"prodex login --device-auth\",\n action: LoginMenuAction::Method(LoginMethod::DeviceCode),\n },\n LoginMenuEntry {\n title: \"Provide your own API key (OpenAI/API-compatible)\",\n provider: \"OpenAI / local / OpenAI-compatible endpoint\",\n auth: \"API key stored in the selected Prodex profile\",\n usage: \"Use OpenAI API billing, or provide a compatible base URL for local/custom endpoints.\",\n command: \"prodex login --with-api-key [--base-url URL]\",\n action: LoginMenuAction::Method(LoginMethod::ApiKey),\n },\n LoginMenuEntry {\n title: \"Google Gemini OAuth\",\n provider: \"Google Gemini\",\n auth: \"Google OAuth profile\",\n usage: \"Reusable Gemini profile for prodex s --provider gemini, including OAuth rotation.\",\n command: \"prodex login --with-google\",\n action: LoginMenuAction::Method(LoginMethod::Google),\n },\n LoginMenuEntry {\n title: \"Google Gemini API key\",\n provider: \"Google Gemini\",\n auth: \"Runtime API key only\",\n usage: \"Not a persisted login profile; pass GEMINI_API_KEY, GEMINI_API_KEYS, GOOGLE_API_KEY(S), or --api-key when launching Gemini.\",\n command: \"GEMINI_API_KEY=... prodex s --provider gemini --model gemini-2.5-pro\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::GeminiApiKey),\n },\n LoginMenuEntry {\n title: \"Anthropic Claude OAuth\",\n provider: \"Anthropic Claude\",\n auth: \"Claude Code OAuth profile\",\n usage: \"Reusable Anthropic profile for prodex s --provider anthropic without API-key storage.\",\n command: \"prodex login --with-claude\",\n action: LoginMenuAction::Method(LoginMethod::Claude),\n },\n LoginMenuEntry {\n title: \"Google Antigravity CLI\",\n provider: \"Google Antigravity\",\n auth: \"Antigravity CLI keyring / Google Sign-In\",\n usage: \"Authenticate the native agy CLI used by prodex s gemini --cli agy.\",\n command: \"prodex login --with-antigravity\",\n action: LoginMenuAction::Method(LoginMethod::Antigravity),\n },\n LoginMenuEntry {\n title: \"Anthropic API key\",\n provider: \"Anthropic Claude\",\n auth: \"Runtime API key only\",\n usage: \"Not a persisted login profile; pass ANTHROPIC_API_KEY(S) or --api-key.\",\n command: \"ANTHROPIC_API_KEY=... prodex s --provider anthropic --model claude-sonnet-4-6\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::AnthropicApiKey),\n },\n LoginMenuEntry {\n title: \"DeepSeek API key\",\n provider: \"DeepSeek\",\n auth: \"Runtime API key only\",\n usage: \"DeepSeek has no OAuth login in Prodex; use an API key for the provider adapter.\",\n command: \"DEEPSEEK_API_KEY=... prodex s --provider deepseek --model deepseek-v4-pro\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::DeepSeekApiKey),\n },\n LoginMenuEntry {\n title: \"GitHub Copilot import\",\n provider: \"GitHub Copilot\",\n auth: \"Existing Copilot CLI account import\",\n usage: \"Record the Copilot identity from local Copilot CLI state, then launch with --provider copilot.\",\n command: \"prodex profile import copilot\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::CopilotImport),\n },\n ];\n ENTRIES\n}\n\npub(super) fn prompt_login_menu_action() -> Result<LoginMenuAction> {\n if let Some(action) = prompt_login_menu_action_raw()? {\n return Ok(action);\n }\n prompt_login_menu_action_numbered()\n}\n\nfn prompt_login_menu_action_raw() -> Result<Option<LoginMenuAction>> {\n let entries = login_menu_entries();\n let mut tui = match LoginMenuTui::stderr(\"login TUI\") {\n Ok(tui) => tui,\n Err(_) => return Ok(None),\n };\n let mut selected = 0usize;\n let mut offset = 0usize;\n\n loop {\n let size = tui\n .terminal\n .size()\n .context(\"failed to read login TUI terminal size\")?;\n let layout = login_menu_layout_for_rows(usize::from(size.height), entries.len());\n offset = login_menu_window_offset(selected, offset, layout.visible_items, entries.len());\n tui.terminal\n .draw(|frame| render_login_menu_tui(frame, entries, selected, offset, layout))\n .context(\"failed to draw login TUI\")?;\n if let Some(action) = apply_login_menu_key(\n read_login_menu_key()?,\n &mut selected,\n layout.visible_items,\n entries,\n )? {\n return Ok(Some(action));\n }\n }\n}\n\nfn read_login_menu_key() -> Result<LoginMenuKey> {\n loop {\n match event::read().context(\"failed to read login TUI input\")? {\n Event::Key(key) if key.kind == KeyEventKind::Press => {\n return Ok(login_menu_key_from_event(key));\n }\n Event::Resize(_, _) => return Ok(LoginMenuKey::Ignore),\n _ => {}\n }\n }\n}\n\nfn apply_login_menu_key(\n key: LoginMenuKey,\n selected: &mut usize,\n visible_items: usize,\n entries: &[LoginMenuEntry],\n) -> Result<Option<LoginMenuAction>> {\n let last = entries.len().saturating_sub(1);\n match key {\n LoginMenuKey::Up => *selected = selected.saturating_sub(1),\n LoginMenuKey::Down => *selected = (*selected + 1).min(last),\n LoginMenuKey::PageUp => {\n *selected = selected.saturating_sub(login_menu_page_step(visible_items))\n }\n LoginMenuKey::PageDown => {\n *selected = (*selected + login_menu_page_step(visible_items)).min(last)\n }\n LoginMenuKey::Home => *selected = 0,\n LoginMenuKey::End => *selected = last,\n LoginMenuKey::Enter => return Ok(Some(entries[*selected].action)),\n LoginMenuKey::Digit(index) => {\n return Ok(entries\n .get(index.saturating_sub(1))\n .map(|entry| entry.action));\n }\n LoginMenuKey::Cancel => bail!(\"login cancelled\"),\n LoginMenuKey::Ignore => {}\n }\n Ok(None)\n}\n\nfn login_menu_page_step(visible_items: usize) -> usize {\n visible_items.saturating_sub(1).max(1)\n}\n\nfn login_menu_key_from_event(key: KeyEvent) -> LoginMenuKey {\n match key.code {\n KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('K') => LoginMenuKey::Up,\n KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('J') => LoginMenuKey::Down,\n KeyCode::PageUp | KeyCode::Char('u') | KeyCode::Char('U') => LoginMenuKey::PageUp,\n KeyCode::PageDown | KeyCode::Char('d') | KeyCode::Char('D') => LoginMenuKey::PageDown,\n KeyCode::Home | KeyCode::Char('g') => LoginMenuKey::Home,\n KeyCode::End | KeyCode::Char('G') => LoginMenuKey::End,\n KeyCode::Enter => LoginMenuKey::Enter,\n KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => LoginMenuKey::Cancel,\n KeyCode::Char('c') | KeyCode::Char('z')\n if key.modifiers.contains(KeyModifiers::CONTROL) =>\n {\n LoginMenuKey::Cancel\n }\n KeyCode::Char(ch) if ('1'..='9').contains(&ch) => {\n LoginMenuKey::Digit(ch.to_digit(10).unwrap_or(0) as usize)\n }\n _ => LoginMenuKey::Ignore,\n }\n}\n\nfn prompt_login_menu_action_numbered() -> Result<LoginMenuAction> {\n let entries = login_menu_entries();\n let mut stderr = io::stderr();\n writeln!(stderr, \"Choose login method:\")?;\n for (index, entry) in entries.iter().enumerate() {\n writeln!(stderr, \" {}. {}\", index + 1, entry.title)?;\n writeln!(stderr, \" Provider: {}\", entry.provider)?;\n writeln!(stderr, \" Auth: {}\", entry.auth)?;\n writeln!(stderr, \" Use: {}\", entry.usage)?;\n writeln!(stderr, \" Command: {}\", entry.command)?;\n }\n loop {\n write!(stderr, \"Select login method [1]: \")?;\n stderr.flush()?;\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read login method\")?;\n let selected = input.trim();\n if selected.is_empty() {\n return Ok(entries[0].action);\n }\n if let Ok(index) = selected.parse::<usize>()\n && (1..=entries.len()).contains(&index)\n {\n return Ok(entries[index - 1].action);\n }\n writeln!(stderr, \"Enter 1 through {}.\", entries.len())?;\n }\n}\n\npub(super) fn show_login_guidance(kind: LoginGuidanceKind) -> Result<()> {\n let entry = login_menu_entries()\n .iter()\n .find(|entry| entry.action == LoginMenuAction::Guidance(kind))\n .context(\"login guidance entry is missing\")?;\n let mut stderr = io::stderr();\n writeln!(stderr)?;\n writeln!(stderr, \"Provider guidance: {}\", entry.title)?;\n writeln!(stderr, \" Provider: {}\", entry.provider)?;\n writeln!(stderr, \" Auth: {}\", entry.auth)?;\n writeln!(stderr, \" Use: {}\", entry.usage)?;\n writeln!(stderr, \" Command: {}\", entry.command)?;\n writeln!(\n stderr,\n \" Note: this path is selected at runtime, not stored by prodex login.\"\n )?;\n writeln!(stderr)?;\n write!(\n stderr,\n \"Press Enter to return to login methods, or Ctrl-C to exit.\"\n )?;\n stderr.flush()?;\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read login guidance acknowledgement\")?;\n Ok(())\n}\n\nfn login_menu_layout_for_rows(rows: usize, entry_count: usize) -> LoginMenuLayout {\n if entry_count == 0 {\n return LoginMenuLayout {\n visible_items: 0,\n compact: true,\n };\n }\n\n let rows = rows.max(8);\n let compact = rows < 16;\n let reserved_rows = if compact { 5 } else { 7 };\n let min_visible = LOGIN_MENU_MIN_VISIBLE_ITEMS.min(entry_count);\n let visible_items = rows\n .saturating_sub(reserved_rows)\n .max(min_visible)\n .min(entry_count);\n LoginMenuLayout {\n visible_items,\n compact,\n }\n}\n\nfn login_menu_window_offset(\n selected: usize,\n current_offset: usize,\n visible_items: usize,\n entry_count: usize,\n) -> usize {\n if entry_count == 0 || visible_items == 0 {\n return 0;\n }\n let max_offset = entry_count.saturating_sub(visible_items);\n if selected < current_offset {\n selected.min(max_offset)\n } else if selected >= current_offset.saturating_add(visible_items) {\n selected\n .saturating_add(1)\n .saturating_sub(visible_items)\n .min(max_offset)\n } else {\n current_offset.min(max_offset)\n }\n}\n\nfn render_login_menu_tui(\n frame: &mut ratatui::Frame<'_>,\n entries: &[LoginMenuEntry],\n selected: usize,\n offset: usize,\n layout: LoginMenuLayout,\n) {\n let area = frame.area();\n let chunks = Layout::default()\n .direction(Direction::Vertical)\n .constraints([\n Constraint::Length(3),\n Constraint::Min(4),\n Constraint::Length(if layout.compact { 5 } else { 7 }),\n ])\n .split(area);\n\n let visible_items = layout.visible_items.max(1).min(entries.len().max(1));\n let end = offset.saturating_add(visible_items).min(entries.len());\n render_login_menu_header(frame, chunks[0], offset, end, entries.len());\n let items = login_menu_items(entries, selected, offset, end);\n let list = List::new(items).block(\n Block::default()\n .borders(Borders::LEFT | Borders::RIGHT)\n .border_style(tui_border_style()),\n );\n frame.render_widget(list, chunks[1]);\n render_login_menu_detail(frame, chunks[2], entries, selected, layout);\n}\n\nfn render_login_menu_header(\n frame: &mut ratatui::Frame<'_>,\n area: ratatui::layout::Rect,\n offset: usize,\n end: usize,\n entry_count: usize,\n) {\n let header = Paragraph::new(Line::from(vec![\n Span::styled(\"Prodex Login\", tui_title_style()),\n Span::raw(\" \"),\n Span::styled(\n format!(\n \"methods {}-{} of {}\",\n offset.saturating_add(1).min(entry_count),\n end,\n entry_count\n ),\n tui_secondary_style(),\n ),\n ]))\n .block(tui_connected_header_block(tui_border_style()));\n frame.render_widget(header, area);\n}\n\nfn login_menu_items(\n entries: &[LoginMenuEntry],\n selected: usize,\n offset: usize,\n end: usize,\n) -> Vec<ListItem<'static>> {\n if entries.is_empty() {\n return vec![ListItem::new(Line::styled(\n \"No login methods available\",\n tui_error_style(),\n ))];\n }\n entries\n .iter()\n .enumerate()\n .take(end)\n .skip(offset)\n .map(|(index, entry)| {\n let marker = if index == selected {\n \">\"\n } else if index == offset && offset > 0 {\n \"^\"\n } else if index + 1 == end && end < entries.len() {\n \"v\"\n } else {\n \" \"\n };\n let style = if index == selected {\n tui_hint_style().add_modifier(Modifier::BOLD)\n } else {\n tui_primary_style()\n };\n ListItem::new(Line::from(vec![\n Span::styled(format!(\"{marker} {:>2}. \", index + 1), style),\n Span::styled(entry.title, style),\n Span::raw(\" \"),\n Span::styled(format!(\"[{}]\", entry.auth), tui_hint_style()),\n ]))\n })\n .collect()\n}\n\nfn render_login_menu_detail(\n frame: &mut ratatui::Frame<'_>,\n area: ratatui::layout::Rect,\n entries: &[LoginMenuEntry],\n selected: usize,\n layout: LoginMenuLayout,\n) {\n let detail = if entries.is_empty() {\n Text::from(Line::styled(\n \"No login methods are registered.\",\n tui_error_style(),\n ))\n } else {\n login_menu_detail_text(\n &entries[selected.min(entries.len().saturating_sub(1))],\n layout,\n )\n };\n let detail = Paragraph::new(detail)\n .block(tui_connected_footer_block(tui_border_style()))\n .wrap(ratatui::widgets::Wrap { trim: false });\n frame.render_widget(detail, area);\n}\n\nfn login_menu_detail_text(entry: &LoginMenuEntry, layout: LoginMenuLayout) -> Text<'static> {\n if layout.compact {\n return Text::from(vec![\n Line::from(vec![\n Span::styled(\"Select login method \", tui_secondary_style()),\n Span::styled(\"Provider \", tui_secondary_style()),\n Span::styled(entry.provider, tui_success_style()),\n Span::raw(\" | \"),\n Span::styled(entry.auth, tui_hint_style()),\n ]),\n Line::from(vec![\n Span::styled(\"Use \", tui_secondary_style()),\n Span::raw(entry.usage),\n ]),\n Line::from(vec![\n Span::styled(\"Cmd \", tui_secondary_style()),\n Span::styled(entry.command, tui_hint_style()),\n ]),\n ]);\n }\n\n Text::from(vec![\n Line::from(vec![\n Span::styled(\"Select login method \", tui_secondary_style()),\n Span::styled(\"Provider \", tui_secondary_style()),\n Span::styled(entry.provider, tui_success_style()),\n ]),\n Line::from(vec![\n Span::styled(\"Auth \", tui_secondary_style()),\n Span::styled(entry.auth, tui_hint_style()),\n ]),\n Line::from(vec![\n Span::styled(\"Use \", tui_secondary_style()),\n Span::raw(entry.usage),\n ]),\n Line::from(vec![\n Span::styled(\"Command \", tui_secondary_style()),\n Span::styled(entry.command, tui_hint_style()),\n ]),\n Line::from(Span::styled(\n \"Select login method | Up/Down move | PageUp/PageDown scroll | Enter select | 1-9 quick select | q cancel\",\n tui_detail_style().add_modifier(Modifier::ITALIC),\n )),\n ])\n}\n\n#[cfg(test)]\n#[path = \"../../../tests/src/profile_commands/login_menu.rs\"]\nmod login_menu_tests;\n"}, "f2451df6c5f51ff0ef8e83b16b4663c2f12bd855": {"t": 1785757002.0759947, "n": 17947, "h": "da16a57b3aa7f830", "c": "use super::LoginMethod;\nuse anyhow::{Context, Result, bail};\nuse crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};\nuse ratatui::layout::{Constraint, Direction, Layout};\nuse ratatui::style::Modifier;\nuse ratatui::text::{Line, Span, Text};\nuse ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};\nuse std::io::{self, IsTerminal, Write};\nuse terminal_ui::{\n tui_border_style, tui_connected_footer_block, tui_connected_header_block, tui_detail_style,\n tui_error_style, tui_hint_style, tui_primary_style, tui_secondary_style, tui_success_style,\n tui_title_style,\n};\n\nconst LOGIN_MENU_MIN_VISIBLE_ITEMS: usize = 3;\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub(super) enum LoginMenuAction {\n Method(LoginMethod),\n Guidance(LoginGuidanceKind),\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub(super) enum LoginGuidanceKind {\n GeminiApiKey,\n AnthropicApiKey,\n DeepSeekApiKey,\n CopilotImport,\n}\n\n#[derive(Debug, Clone, Copy)]\nstruct LoginMenuEntry {\n title: &'static str,\n provider: &'static str,\n auth: &'static str,\n usage: &'static str,\n command: &'static str,\n action: LoginMenuAction,\n}\n\n#[derive(Debug, Clone, Copy)]\nstruct LoginMenuLayout {\n visible_items: usize,\n compact: bool,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum LoginMenuKey {\n Up,\n Down,\n PageUp,\n PageDown,\n Home,\n End,\n Enter,\n Cancel,\n Digit(usize),\n Ignore,\n}\n\ntype LoginMenuTui = terminal_ui::AlternateScreenTerminal<io::Stderr>;\n\npub(super) fn login_prompt_is_interactive() -> bool {\n io::stdin().is_terminal() && io::stderr().is_terminal()\n}\n\nfn login_menu_entries() -> &'static [LoginMenuEntry] {\n const ENTRIES: &[LoginMenuEntry] = &[\n LoginMenuEntry {\n title: \"Sign in with ChatGPT (OpenAI OAuth)\",\n provider: \"OpenAI / Codex\",\n auth: \"ChatGPT OAuth\",\n usage: \"Default quota-aware profile pool for prodex run, prodex s, and prodex caveman.\",\n command: \"prodex login\",\n action: LoginMenuAction::Method(LoginMethod::ChatGpt),\n },\n LoginMenuEntry {\n title: \"OpenAI device code\",\n provider: \"OpenAI / Codex\",\n auth: \"Device-code OAuth\",\n usage: \"Same OpenAI/Codex profile type, useful on a terminal without a local browser.\",\n command: \"prodex login --device-auth\",\n action: LoginMenuAction::Method(LoginMethod::DeviceCode),\n },\n LoginMenuEntry {\n title: \"Provide your own API key (OpenAI/API-compatible)\",\n provider: \"OpenAI / local / OpenAI-compatible endpoint\",\n auth: \"API key stored in the selected Prodex profile\",\n usage: \"Use OpenAI API billing, or provide a compatible base URL for local/custom endpoints.\",\n command: \"prodex login --with-api-key [--base-url URL]\",\n action: LoginMenuAction::Method(LoginMethod::ApiKey),\n },\n LoginMenuEntry {\n title: \"Google Gemini OAuth\",\n provider: \"Google Gemini\",\n auth: \"Google OAuth profile\",\n usage: \"Reusable Gemini profile for prodex s --provider gemini, including OAuth rotation.\",\n command: \"prodex login --with-google\",\n action: LoginMenuAction::Method(LoginMethod::Google),\n },\n LoginMenuEntry {\n title: \"Google Gemini API key\",\n provider: \"Google Gemini\",\n auth: \"Runtime API key only\",\n usage: \"Not a persisted login profile; pass GEMINI_API_KEY, GEMINI_API_KEYS, GOOGLE_API_KEY(S), or --api-key when launching Gemini.\",\n command: \"GEMINI_API_KEY=... prodex s --provider gemini --model gemini-2.5-pro\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::GeminiApiKey),\n },\n LoginMenuEntry {\n title: \"Anthropic Claude OAuth\",\n provider: \"Anthropic Claude\",\n auth: \"Claude Code OAuth profile\",\n usage: \"Reusable Anthropic profile for prodex s --provider anthropic without API-key storage.\",\n command: \"prodex login --with-claude\",\n action: LoginMenuAction::Method(LoginMethod::Claude),\n },\n LoginMenuEntry {\n title: \"Google Antigravity CLI\",\n provider: \"Google Antigravity\",\n auth: \"Antigravity CLI keyring / Google Sign-In\",\n usage: \"Authenticate the native agy CLI used by prodex s gemini --cli agy.\",\n command: \"prodex login --with-antigravity\",\n action: LoginMenuAction::Method(LoginMethod::Antigravity),\n },\n LoginMenuEntry {\n title: \"Anthropic API key\",\n provider: \"Anthropic Claude\",\n auth: \"Runtime API key only\",\n usage: \"Not a persisted login profile; pass ANTHROPIC_API_KEY(S) or --api-key.\",\n command: \"ANTHROPIC_API_KEY=... prodex s --provider anthropic --model claude-sonnet-4-6\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::AnthropicApiKey),\n },\n LoginMenuEntry {\n title: \"DeepSeek API key\",\n provider: \"DeepSeek\",\n auth: \"Runtime API key only\",\n usage: \"DeepSeek has no OAuth login in Prodex; use an API key for the provider adapter.\",\n command: \"DEEPSEEK_API_KEY=... prodex s --provider deepseek --model deepseek-v4-pro\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::DeepSeekApiKey),\n },\n LoginMenuEntry {\n title: \"GitHub Copilot import\",\n provider: \"GitHub Copilot\",\n auth: \"Existing Copilot CLI account import\",\n usage: \"Record the Copilot identity from local Copilot CLI state, then launch with --provider copilot.\",\n command: \"prodex profile import copilot\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::CopilotImport),\n },\n ];\n ENTRIES\n}\n\npub(super) fn prompt_login_menu_action() -> Result<LoginMenuAction> {\n if let Some(action) = prompt_login_menu_action_raw()? {\n return Ok(action);\n }\n prompt_login_menu_action_numbered()\n}\n\nfn prompt_login_menu_action_raw() -> Result<Option<LoginMenuAction>> {\n let entries = login_menu_entries();\n let mut tui = match LoginMenuTui::stderr(\"login TUI\") {\n Ok(tui) => tui,\n Err(_) => return Ok(None),\n };\n let mut selected = 0usize;\n let mut offset = 0usize;\n\n loop {\n let size = tui\n .terminal\n .size()\n .context(\"failed to read login TUI terminal size\")?;\n let layout = login_menu_layout_for_rows(usize::from(size.height), entries.len());\n offset = login_menu_window_offset(selected, offset, layout.visible_items, entries.len());\n tui.terminal\n .draw(|frame| render_login_menu_tui(frame, entries, selected, offset, layout))\n .context(\"failed to draw login TUI\")?;\n let key = loop {\n match event::read().context(\"failed to read login TUI input\")? {\n Event::Key(key) if key.kind == KeyEventKind::Press => {\n break login_menu_key_from_event(key);\n }\n Event::Resize(_, _) => {\n break LoginMenuKey::Ignore;\n }\n _ => {}\n }\n };\n match key {\n LoginMenuKey::Up => {\n selected = selected.saturating_sub(1);\n }\n LoginMenuKey::Down => {\n selected = (selected + 1).min(entries.len().saturating_sub(1));\n }\n LoginMenuKey::PageUp => {\n let step = layout.visible_items.saturating_sub(1).max(1);\n selected = selected.saturating_sub(step);\n }\n LoginMenuKey::PageDown => {\n let step = layout.visible_items.saturating_sub(1).max(1);\n selected = (selected + step).min(entries.len().saturating_sub(1));\n }\n LoginMenuKey::Home => {\n selected = 0;\n }\n LoginMenuKey::End => {\n selected = entries.len().saturating_sub(1);\n }\n LoginMenuKey::Enter => return Ok(Some(entries[selected].action)),\n LoginMenuKey::Digit(index) => {\n if let Some(entry) = entries.get(index.saturating_sub(1)) {\n return Ok(Some(entry.action));\n }\n }\n LoginMenuKey::Cancel => bail!(\"login cancelled\"),\n LoginMenuKey::Ignore => {}\n }\n }\n}\n\nfn login_menu_key_from_event(key: KeyEvent) -> LoginMenuKey {\n match key.code {\n KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('K') => LoginMenuKey::Up,\n KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('J') => LoginMenuKey::Down,\n KeyCode::PageUp | KeyCode::Char('u') | KeyCode::Char('U') => LoginMenuKey::PageUp,\n KeyCode::PageDown | KeyCode::Char('d') | KeyCode::Char('D') => LoginMenuKey::PageDown,\n KeyCode::Home | KeyCode::Char('g') => LoginMenuKey::Home,\n KeyCode::End | KeyCode::Char('G') => LoginMenuKey::End,\n KeyCode::Enter => LoginMenuKey::Enter,\n KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => LoginMenuKey::Cancel,\n KeyCode::Char('c') | KeyCode::Char('z')\n if key.modifiers.contains(KeyModifiers::CONTROL) =>\n {\n LoginMenuKey::Cancel\n }\n KeyCode::Char(ch) if ('1'..='9').contains(&ch) => {\n LoginMenuKey::Digit(ch.to_digit(10).unwrap_or(0) as usize)\n }\n _ => LoginMenuKey::Ignore,\n }\n}\n\nfn prompt_login_menu_action_numbered() -> Result<LoginMenuAction> {\n let entries = login_menu_entries();\n let mut stderr = io::stderr();\n writeln!(stderr, \"Choose login method:\")?;\n for (index, entry) in entries.iter().enumerate() {\n writeln!(stderr, \" {}. {}\", index + 1, entry.title)?;\n writeln!(stderr, \" Provider: {}\", entry.provider)?;\n writeln!(stderr, \" Auth: {}\", entry.auth)?;\n writeln!(stderr, \" Use: {}\", entry.usage)?;\n writeln!(stderr, \" Command: {}\", entry.command)?;\n }\n loop {\n write!(stderr, \"Select login method [1]: \")?;\n stderr.flush()?;\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read login method\")?;\n let selected = input.trim();\n if selected.is_empty() {\n return Ok(entries[0].action);\n }\n if let Ok(index) = selected.parse::<usize>()\n && (1..=entries.len()).contains(&index)\n {\n return Ok(entries[index - 1].action);\n }\n writeln!(stderr, \"Enter 1 through {}.\", entries.len())?;\n }\n}\n\npub(super) fn show_login_guidance(kind: LoginGuidanceKind) -> Result<()> {\n let entry = login_menu_entries()\n .iter()\n .find(|entry| entry.action == LoginMenuAction::Guidance(kind))\n .context(\"login guidance entry is missing\")?;\n let mut stderr = io::stderr();\n writeln!(stderr)?;\n writeln!(stderr, \"Provider guidance: {}\", entry.title)?;\n writeln!(stderr, \" Provider: {}\", entry.provider)?;\n writeln!(stderr, \" Auth: {}\", entry.auth)?;\n writeln!(stderr, \" Use: {}\", entry.usage)?;\n writeln!(stderr, \" Command: {}\", entry.command)?;\n writeln!(\n stderr,\n \" Note: this path is selected at runtime, not stored by prodex login.\"\n )?;\n writeln!(stderr)?;\n write!(\n stderr,\n \"Press Enter to return to login methods, or Ctrl-C to exit.\"\n )?;\n stderr.flush()?;\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read login guidance acknowledgement\")?;\n Ok(())\n}\n\nfn login_menu_layout_for_rows(rows: usize, entry_count: usize) -> LoginMenuLayout {\n if entry_count == 0 {\n return LoginMenuLayout {\n visible_items: 0,\n compact: true,\n };\n }\n\n let rows = rows.max(8);\n let compact = rows < 16;\n let reserved_rows = if compact { 5 } else { 7 };\n let min_visible = LOGIN_MENU_MIN_VISIBLE_ITEMS.min(entry_count);\n let visible_items = rows\n .saturating_sub(reserved_rows)\n .max(min_visible)\n .min(entry_count);\n LoginMenuLayout {\n visible_items,\n compact,\n }\n}\n\nfn login_menu_window_offset(\n selected: usize,\n current_offset: usize,\n visible_items: usize,\n entry_count: usize,\n) -> usize {\n if entry_count == 0 || visible_items == 0 {\n return 0;\n }\n let max_offset = entry_count.saturating_sub(visible_items);\n if selected < current_offset {\n selected.min(max_offset)\n } else if selected >= current_offset.saturating_add(visible_items) {\n selected\n .saturating_add(1)\n .saturating_sub(visible_items)\n .min(max_offset)\n } else {\n current_offset.min(max_offset)\n }\n}\n\nfn render_login_menu_tui(\n frame: &mut ratatui::Frame<'_>,\n entries: &[LoginMenuEntry],\n selected: usize,\n offset: usize,\n layout: LoginMenuLayout,\n) {\n let area = frame.area();\n let chunks = Layout::default()\n .direction(Direction::Vertical)\n .constraints([\n Constraint::Length(3),\n Constraint::Min(4),\n Constraint::Length(if layout.compact { 5 } else { 7 }),\n ])\n .split(area);\n\n let visible_items = layout.visible_items.max(1).min(entries.len().max(1));\n let end = offset.saturating_add(visible_items).min(entries.len());\n let header = Paragraph::new(Line::from(vec![\n Span::styled(\"Prodex Login\", tui_title_style()),\n Span::raw(\" \"),\n Span::styled(\n format!(\n \"methods {}-{} of {}\",\n offset.saturating_add(1).min(entries.len()),\n end,\n entries.len()\n ),\n tui_secondary_style(),\n ),\n ]))\n .block(tui_connected_header_block(tui_border_style()));\n frame.render_widget(header, chunks[0]);\n\n let items = if entries.is_empty() {\n vec![ListItem::new(Line::styled(\n \"No login methods available\",\n tui_error_style(),\n ))]\n } else {\n entries\n .iter()\n .enumerate()\n .take(end)\n .skip(offset)\n .map(|(index, entry)| {\n let marker = if index == selected {\n \">\"\n } else if index == offset && offset > 0 {\n \"^\"\n } else if index + 1 == end && end < entries.len() {\n \"v\"\n } else {\n \" \"\n };\n let style = if index == selected {\n tui_hint_style().add_modifier(Modifier::BOLD)\n } else {\n tui_primary_style()\n };\n ListItem::new(Line::from(vec![\n Span::styled(format!(\"{marker} {:>2}. \", index + 1), style),\n Span::styled(entry.title, style),\n Span::raw(\" \"),\n Span::styled(format!(\"[{}]\", entry.auth), tui_hint_style()),\n ]))\n })\n .collect()\n };\n let list = List::new(items).block(\n Block::default()\n .borders(Borders::LEFT | Borders::RIGHT)\n .border_style(tui_border_style()),\n );\n frame.render_widget(list, chunks[1]);\n\n let detail = if entries.is_empty() {\n Text::from(Line::styled(\n \"No login methods are registered.\",\n tui_error_style(),\n ))\n } else {\n login_menu_detail_text(\n &entries[selected.min(entries.len().saturating_sub(1))],\n layout,\n )\n };\n let detail = Paragraph::new(detail)\n .block(tui_connected_footer_block(tui_border_style()))\n .wrap(ratatui::widgets::Wrap { trim: false });\n frame.render_widget(detail, chunks[2]);\n}\n\nfn login_menu_detail_text(entry: &LoginMenuEntry, layout: LoginMenuLayout) -> Text<'static> {\n if layout.compact {\n return Text::from(vec![\n Line::from(vec![\n Span::styled(\"Select login method \", tui_secondary_style()),\n Span::styled(\"Provider \", tui_secondary_style()),\n Span::styled(entry.provider, tui_success_style()),\n Span::raw(\" | \"),\n Span::styled(entry.auth, tui_hint_style()),\n ]),\n Line::from(vec![\n Span::styled(\"Use \", tui_secondary_style()),\n Span::raw(entry.usage),\n ]),\n Line::from(vec![\n Span::styled(\"Cmd \", tui_secondary_style()),\n Span::styled(entry.command, tui_hint_style()),\n ]),\n ]);\n }\n\n Text::from(vec![\n Line::from(vec![\n Span::styled(\"Select login method \", tui_secondary_style()),\n Span::styled(\"Provider \", tui_secondary_style()),\n Span::styled(entry.provider, tui_success_style()),\n ]),\n Line::from(vec![\n Span::styled(\"Auth \", tui_secondary_style()),\n Span::styled(entry.auth, tui_hint_style()),\n ]),\n Line::from(vec![\n Span::styled(\"Use \", tui_secondary_style()),\n Span::raw(entry.usage),\n ]),\n Line::from(vec![\n Span::styled(\"Command \", tui_secondary_style()),\n Span::styled(entry.command, tui_hint_style()),\n ]),\n Line::from(Span::styled(\n \"Select login method | Up/Down move | PageUp/PageDown scroll | Enter select | 1-9 quick select | q cancel\",\n tui_detail_style().add_modifier(Modifier::ITALIC),\n )),\n ])\n}\n\n#[cfg(test)]\n#[path = \"../../../tests/src/profile_commands/login_menu.rs\"]\nmod login_menu_tests;\n"}, "5b074ea421df906052c08d2bf2c94c7476836cba": {"t": 1785757003.4932158, "n": 19265, "h": "4547cc848907432c", "c": "use super::LoginMethod;\nuse anyhow::{Context, Result, bail};\nuse crossterm::cursor::{Hide, Show};\nuse crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};\nuse crossterm::terminal::{\n EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,\n};\nuse ratatui::Terminal;\nuse ratatui::backend::CrosstermBackend;\nuse ratatui::layout::{Constraint, Direction, Layout};\nuse ratatui::style::Modifier;\nuse ratatui::text::{Line, Span, Text};\nuse ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};\nuse std::io::{self, IsTerminal, Write};\nuse terminal_ui::{\n tui_border_style, tui_connected_footer_block, tui_connected_header_block, tui_detail_style,\n tui_error_style, tui_hint_style, tui_primary_style, tui_secondary_style, tui_success_style,\n tui_title_style,\n};\n\nconst LOGIN_MENU_MIN_VISIBLE_ITEMS: usize = 3;\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub(super) enum LoginMenuAction {\n Method(LoginMethod),\n Guidance(LoginGuidanceKind),\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub(super) enum LoginGuidanceKind {\n GeminiApiKey,\n AnthropicApiKey,\n DeepSeekApiKey,\n CopilotImport,\n}\n\n#[derive(Debug, Clone, Copy)]\nstruct LoginMenuEntry {\n title: &'static str,\n provider: &'static str,\n auth: &'static str,\n usage: &'static str,\n command: &'static str,\n action: LoginMenuAction,\n}\n\n#[derive(Debug, Clone, Copy)]\nstruct LoginMenuLayout {\n visible_items: usize,\n compact: bool,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum LoginMenuKey {\n Up,\n Down,\n PageUp,\n PageDown,\n Home,\n End,\n Enter,\n Cancel,\n Digit(usize),\n Ignore,\n}\n\nstruct LoginMenuTui {\n terminal: Terminal<CrosstermBackend<io::Stderr>>,\n}\n\nimpl LoginMenuTui {\n fn new() -> Result<Self> {\n enable_raw_mode().context(\"failed to enable login TUI raw mode\")?;\n let mut stderr = io::stderr();\n if let Err(err) = crossterm::execute!(stderr, EnterAlternateScreen, Hide) {\n let _ = disable_raw_mode();\n return Err(err).context(\"failed to enter login TUI alternate screen\");\n }\n let backend = CrosstermBackend::new(stderr);\n let terminal = match Terminal::new(backend) {\n Ok(terminal) => terminal,\n Err(err) => {\n let mut stderr = io::stderr();\n let _ = crossterm::execute!(stderr, Show, LeaveAlternateScreen);\n let _ = disable_raw_mode();\n return Err(err).context(\"failed to initialize login TUI terminal\");\n }\n };\n Ok(Self { terminal })\n }\n}\n\nimpl Drop for LoginMenuTui {\n fn drop(&mut self) {\n let _ = disable_raw_mode();\n let _ = crossterm::execute!(self.terminal.backend_mut(), Show, LeaveAlternateScreen);\n let _ = self.terminal.show_cursor();\n }\n}\n\npub(super) fn login_prompt_is_interactive() -> bool {\n io::stdin().is_terminal() && io::stderr().is_terminal()\n}\n\nfn login_menu_entries() -> &'static [LoginMenuEntry] {\n const ENTRIES: &[LoginMenuEntry] = &[\n LoginMenuEntry {\n title: \"Sign in with ChatGPT (OpenAI OAuth)\",\n provider: \"OpenAI / Codex\",\n auth: \"ChatGPT OAuth\",\n usage: \"Default quota-aware profile pool for prodex run, prodex s, and prodex caveman.\",\n command: \"prodex login\",\n action: LoginMenuAction::Method(LoginMethod::ChatGpt),\n },\n LoginMenuEntry {\n title: \"OpenAI device code\",\n provider: \"OpenAI / Codex\",\n auth: \"Device-code OAuth\",\n usage: \"Same OpenAI/Codex profile type, useful on a terminal without a local browser.\",\n command: \"prodex login --device-auth\",\n action: LoginMenuAction::Method(LoginMethod::DeviceCode),\n },\n LoginMenuEntry {\n title: \"Provide your own API key (OpenAI/API-compatible)\",\n provider: \"OpenAI / local / OpenAI-compatible endpoint\",\n auth: \"API key stored in the selected Prodex profile\",\n usage: \"Use OpenAI API billing, or provide a compatible base URL for local/custom endpoints.\",\n command: \"prodex login --with-api-key [--base-url URL]\",\n action: LoginMenuAction::Method(LoginMethod::ApiKey),\n },\n LoginMenuEntry {\n title: \"Google Gemini OAuth\",\n provider: \"Google Gemini\",\n auth: \"Google OAuth profile\",\n usage: \"Reusable Gemini profile for prodex s --provider gemini, including OAuth rotation.\",\n command: \"prodex login --with-google\",\n action: LoginMenuAction::Method(LoginMethod::Google),\n },\n LoginMenuEntry {\n title: \"Google Gemini API key\",\n provider: \"Google Gemini\",\n auth: \"Runtime API key only\",\n usage: \"Not a persisted login profile; pass GEMINI_API_KEY, GEMINI_API_KEYS, GOOGLE_API_KEY(S), or --api-key when launching Gemini.\",\n command: \"GEMINI_API_KEY=... prodex s --provider gemini --model gemini-2.5-pro\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::GeminiApiKey),\n },\n LoginMenuEntry {\n title: \"Anthropic Claude OAuth\",\n provider: \"Anthropic Claude\",\n auth: \"Claude Code OAuth profile\",\n usage: \"Reusable Anthropic profile for prodex s --provider anthropic without API-key storage.\",\n command: \"prodex login --with-claude\",\n action: LoginMenuAction::Method(LoginMethod::Claude),\n },\n LoginMenuEntry {\n title: \"Google Antigravity CLI\",\n provider: \"Google Antigravity\",\n auth: \"Antigravity CLI keyring / Google Sign-In\",\n usage: \"Authenticate the native agy CLI used by prodex s gemini --cli agy.\",\n command: \"prodex login --with-antigravity\",\n action: LoginMenuAction::Method(LoginMethod::Antigravity),\n },\n LoginMenuEntry {\n title: \"Anthropic API key\",\n provider: \"Anthropic Claude\",\n auth: \"Runtime API key only\",\n usage: \"Not a persisted login profile; pass ANTHROPIC_API_KEY(S) or --api-key.\",\n command: \"ANTHROPIC_API_KEY=... prodex s --provider anthropic --model claude-sonnet-4-6\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::AnthropicApiKey),\n },\n LoginMenuEntry {\n title: \"DeepSeek API key\",\n provider: \"DeepSeek\",\n auth: \"Runtime API key only\",\n usage: \"DeepSeek has no OAuth login in Prodex; use an API key for the provider adapter.\",\n command: \"DEEPSEEK_API_KEY=... prodex s --provider deepseek --model deepseek-v4-pro\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::DeepSeekApiKey),\n },\n LoginMenuEntry {\n title: \"GitHub Copilot import\",\n provider: \"GitHub Copilot\",\n auth: \"Existing Copilot CLI account import\",\n usage: \"Record the Copilot identity from local Copilot CLI state, then launch with --provider copilot.\",\n command: \"prodex profile import copilot\",\n action: LoginMenuAction::Guidance(LoginGuidanceKind::CopilotImport),\n },\n ];\n ENTRIES\n}\n\npub(super) fn prompt_login_menu_action() -> Result<LoginMenuAction> {\n if let Some(action) = prompt_login_menu_action_raw()? {\n return Ok(action);\n }\n prompt_login_menu_action_numbered()\n}\n\nfn prompt_login_menu_action_raw() -> Result<Option<LoginMenuAction>> {\n let entries = login_menu_entries();\n let mut tui = match LoginMenuTui::new() {\n Ok(tui) => tui,\n Err(_) => return Ok(None),\n };\n let mut selected = 0usize;\n let mut offset = 0usize;\n\n loop {\n let size = tui\n .terminal\n .size()\n .context(\"failed to read login TUI terminal size\")?;\n let layout = login_menu_layout_for_rows(usize::from(size.height), entries.len());\n offset = login_menu_window_offset(selected, offset, layout.visible_items, entries.len());\n tui.terminal\n .draw(|frame| render_login_menu_tui(frame, entries, selected, offset, layout))\n .context(\"failed to draw login TUI\")?;\n let key = loop {\n match event::read().context(\"failed to read login TUI input\")? {\n Event::Key(key) if key.kind == KeyEventKind::Press => {\n break login_menu_key_from_event(key);\n }\n Event::Resize(_, _) => {\n break LoginMenuKey::Ignore;\n }\n _ => {}\n }\n };\n match key {\n LoginMenuKey::Up => {\n selected = selected.saturating_sub(1);\n }\n LoginMenuKey::Down => {\n selected = (selected + 1).min(entries.len().saturating_sub(1));\n }\n LoginMenuKey::PageUp => {\n let step = layout.visible_items.saturating_sub(1).max(1);\n selected = selected.saturating_sub(step);\n }\n LoginMenuKey::PageDown => {\n let step = layout.visible_items.saturating_sub(1).max(1);\n selected = (selected + step).min(entries.len().saturating_sub(1));\n }\n LoginMenuKey::Home => {\n selected = 0;\n }\n LoginMenuKey::End => {\n selected = entries.len().saturating_sub(1);\n }\n LoginMenuKey::Enter => return Ok(Some(entries[selected].action)),\n LoginMenuKey::Digit(index) => {\n if let Some(entry) = entries.get(index.saturating_sub(1)) {\n return Ok(Some(entry.action));\n }\n }\n LoginMenuKey::Cancel => bail!(\"login cancelled\"),\n LoginMenuKey::Ignore => {}\n }\n }\n}\n\nfn login_menu_key_from_event(key: KeyEvent) -> LoginMenuKey {\n match key.code {\n KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('K') => LoginMenuKey::Up,\n KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('J') => LoginMenuKey::Down,\n KeyCode::PageUp | KeyCode::Char('u') | KeyCode::Char('U') => LoginMenuKey::PageUp,\n KeyCode::PageDown | KeyCode::Char('d') | KeyCode::Char('D') => LoginMenuKey::PageDown,\n KeyCode::Home | KeyCode::Char('g') => LoginMenuKey::Home,\n KeyCode::End | KeyCode::Char('G') => LoginMenuKey::End,\n KeyCode::Enter => LoginMenuKey::Enter,\n KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => LoginMenuKey::Cancel,\n KeyCode::Char('c') | KeyCode::Char('z')\n if key.modifiers.contains(KeyModifiers::CONTROL) =>\n {\n LoginMenuKey::Cancel\n }\n KeyCode::Char(ch) if ('1'..='9').contains(&ch) => {\n LoginMenuKey::Digit(ch.to_digit(10).unwrap_or(0) as usize)\n }\n _ => LoginMenuKey::Ignore,\n }\n}\n\nfn prompt_login_menu_action_numbered() -> Result<LoginMenuAction> {\n let entries = login_menu_entries();\n let mut stderr = io::stderr();\n writeln!(stderr, \"Choose login method:\")?;\n for (index, entry) in entries.iter().enumerate() {\n writeln!(stderr, \" {}. {}\", index + 1, entry.title)?;\n writeln!(stderr, \" Provider: {}\", entry.provider)?;\n writeln!(stderr, \" Auth: {}\", entry.auth)?;\n writeln!(stderr, \" Use: {}\", entry.usage)?;\n writeln!(stderr, \" Command: {}\", entry.command)?;\n }\n loop {\n write!(stderr, \"Select login method [1]: \")?;\n stderr.flush()?;\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read login method\")?;\n let selected = input.trim();\n if selected.is_empty() {\n return Ok(entries[0].action);\n }\n if let Ok(index) = selected.parse::<usize>()\n && (1..=entries.len()).contains(&index)\n {\n return Ok(entries[index - 1].action);\n }\n writeln!(stderr, \"Enter 1 through {}.\", entries.len())?;\n }\n}\n\npub(super) fn show_login_guidance(kind: LoginGuidanceKind) -> Result<()> {\n let entry = login_menu_entries()\n .iter()\n .find(|entry| entry.action == LoginMenuAction::Guidance(kind))\n .context(\"login guidance entry is missing\")?;\n let mut stderr = io::stderr();\n writeln!(stderr)?;\n writeln!(stderr, \"Provider guidance: {}\", entry.title)?;\n writeln!(stderr, \" Provider: {}\", entry.provider)?;\n writeln!(stderr, \" Auth: {}\", entry.auth)?;\n writeln!(stderr, \" Use: {}\", entry.usage)?;\n writeln!(stderr, \" Command: {}\", entry.command)?;\n writeln!(\n stderr,\n \" Note: this path is selected at runtime, not stored by prodex login.\"\n )?;\n writeln!(stderr)?;\n write!(\n stderr,\n \"Press Enter to return to login methods, or Ctrl-C to exit.\"\n )?;\n stderr.flush()?;\n let mut input = String::new();\n io::stdin()\n .read_line(&mut input)\n .context(\"failed to read login guidance acknowledgement\")?;\n Ok(())\n}\n\nfn login_menu_layout_for_rows(rows: usize, entry_count: usize) -> LoginMenuLayout {\n if entry_count == 0 {\n return LoginMenuLayout {\n visible_items: 0,\n compact: true,\n };\n }\n\n let rows = rows.max(8);\n let compact = rows < 16;\n let reserved_rows = if compact { 5 } else { 7 };\n let min_visible = LOGIN_MENU_MIN_VISIBLE_ITEMS.min(entry_count);\n let visible_items = rows\n .saturating_sub(reserved_rows)\n .max(min_visible)\n .min(entry_count);\n LoginMenuLayout {\n visible_items,\n compact,\n }\n}\n\nfn login_menu_window_offset(\n selected: usize,\n current_offset: usize,\n visible_items: usize,\n entry_count: usize,\n) -> usize {\n if entry_count == 0 || visible_items == 0 {\n return 0;\n }\n let max_offset = entry_count.saturating_sub(visible_items);\n if selected < current_offset {\n selected.min(max_offset)\n } else if selected >= current_offset.saturating_add(visible_items) {\n selected\n .saturating_add(1)\n .saturating_sub(visible_items)\n .min(max_offset)\n } else {\n current_offset.min(max_offset)\n }\n}\n\nfn render_login_menu_tui(\n frame: &mut ratatui::Frame<'_>,\n entries: &[LoginMenuEntry],\n selected: usize,\n offset: usize,\n layout: LoginMenuLayout,\n) {\n let area = frame.area();\n let chunks = Layout::default()\n .direction(Direction::Vertical)\n .constraints([\n Constraint::Length(3),\n Constraint::Min(4),\n Constraint::Length(if layout.compact { 5 } else { 7 }),\n ])\n .split(area);\n\n let visible_items = layout.visible_items.max(1).min(entries.len().max(1));\n let end = offset.saturating_add(visible_items).min(entries.len());\n let header = Paragraph::new(Line::from(vec![\n Span::styled(\"Prodex Login\", tui_title_style()),\n Span::raw(\" \"),\n Span::styled(\n format!(\n \"methods {}-{} of {}\",\n offset.saturating_add(1).min(entries.len()),\n end,\n entries.len()\n ),\n tui_secondary_style(),\n ),\n ]))\n .block(tui_connected_header_block(tui_border_style()));\n frame.render_widget(header, chunks[0]);\n\n let items = if entries.is_empty() {\n vec![ListItem::new(Line::styled(\n \"No login methods available\",\n tui_error_style(),\n ))]\n } else {\n entries\n .iter()\n .enumerate()\n .take(end)\n .skip(offset)\n .map(|(index, entry)| {\n let marker = if index == selected {\n \">\"\n } else if index == offset && offset > 0 {\n \"^\"\n } else if index + 1 == end && end < entries.len() {\n \"v\"\n } else {\n \" \"\n };\n let style = if index == selected {\n tui_hint_style().add_modifier(Modifier::BOLD)\n } else {\n tui_primary_style()\n };\n ListItem::new(Line::from(vec![\n Span::styled(format!(\"{marker} {:>2}. \", index + 1), style),\n Span::styled(entry.title, style),\n Span::raw(\" \"),\n Span::styled(format!(\"[{}]\", entry.auth), tui_hint_style()),\n ]))\n })\n .collect()\n };\n let list = List::new(items).block(\n Block::default()\n .borders(Borders::LEFT | Borders::RIGHT)\n .border_style(tui_border_style()),\n );\n frame.render_widget(list, chunks[1]);\n\n let detail = if entries.is_empty() {\n Text::from(Line::styled(\n \"No login methods are registered.\",\n tui_error_style(),\n ))\n } else {\n login_menu_detail_text(\n &entries[selected.min(entries.len().saturating_sub(1))],\n layout,\n )\n };\n let detail = Paragraph::new(detail)\n .block(tui_connected_footer_block(tui_border_style()))\n .wrap(ratatui::widgets::Wrap { trim: false });\n frame.render_widget(detail, chunks[2]);\n}\n\nfn login_menu_detail_text(entry: &LoginMenuEntry, layout: LoginMenuLayout) -> Text<'static> {\n if layout.compact {\n return Text::from(vec![\n Line::from(vec![\n Span::styled(\"Select login method \", tui_secondary_style()),\n Span::styled(\"Provider \", tui_secondary_style()),\n Span::styled(entry.provider, tui_success_style()),\n Span::raw(\" | \"),\n Span::styled(entry.auth, tui_hint_style()),\n ]),\n Line::from(vec![\n Span::styled(\"Use \", tui_secondary_style()),\n Span::raw(entry.usage),\n ]),\n Line::from(vec![\n Span::styled(\"Cmd \", tui_secondary_style()),\n Span::styled(entry.command, tui_hint_style()),\n ]),\n ]);\n }\n\n Text::from(vec![\n Line::from(vec![\n Span::styled(\"Select login method \", tui_secondary_style()),\n Span::styled(\"Provider \", tui_secondary_style()),\n Span::styled(entry.provider, tui_success_style()),\n ]),\n Line::from(vec![\n Span::styled(\"Auth \", tui_secondary_style()),\n Span::styled(entry.auth, tui_hint_style()),\n ]),\n Line::from(vec![\n Span::styled(\"Use \", tui_secondary_style()),\n Span::raw(entry.usage),\n ]),\n Line::from(vec![\n Span::styled(\"Command \", tui_secondary_style()),\n Span::styled(entry.command, tui_hint_style()),\n ]),\n Line::from(Span::styled(\n \"Select login method | Up/Down move | PageUp/PageDown scroll | Enter select | 1-9 quick select | q cancel\",\n tui_detail_style().add_modifier(Modifier::ITALIC),\n )),\n ])\n}\n\n#[cfg(test)]\n#[path = \"../../../tests/src/profile_commands/login_menu.rs\"]\nmod login_menu_tests;\n"}}}}