1use std::io;
19
20use super::*;
21
22#[derive(Clone, Eq, PartialEq)]
24pub struct FirstRunRecovery {
25 pub provider: Option<SetupProviderArg>,
27 pub stage: RecoveryStage,
29 pub pending_provider_prompt: bool,
31 pub selected: usize,
33 pub secret_input: String,
35 pub chatgpt_oauth: Option<ChatGptOAuthRecovery>,
37}
38
39impl std::fmt::Debug for FirstRunRecovery {
40 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 formatter
42 .debug_struct("FirstRunRecovery")
43 .field("provider", &self.provider)
44 .field("stage", &self.stage)
45 .field("pending_provider_prompt", &self.pending_provider_prompt)
46 .field("selected", &self.selected)
47 .field(
48 "secret_input",
49 &if self.secret_input.is_empty() { "<empty>" } else { "[redacted]" },
50 )
51 .field("chatgpt_oauth", &self.chatgpt_oauth)
52 .finish()
53 }
54}
55
56impl FirstRunRecovery {
57 pub fn missing_label(&self) -> &'static str {
58 match self.stage {
59 RecoveryStage::ChooseProvider | RecoveryStage::ModelSelection | RecoveryStage::ModelConfigScope => "none",
60 _ => match self.provider {
61 Some(crate::cli::commands::setup::SetupProviderArg::ChatgptCodex) => "ChatGPT OAuth credential",
62 Some(provider) => provider.api_key_env_var().unwrap_or("credential"),
63 None => "ACP agent config",
64 },
65 }
66 }
67
68 pub fn setup(default_provider: SetupProviderArg) -> Self {
69 let selected = match default_provider {
70 SetupProviderArg::ChatgptCodex => 0,
71 SetupProviderArg::Umans => 1,
72 SetupProviderArg::OpencodeGo | SetupProviderArg::OpencodeZen => 2,
73 };
74 Self {
75 provider: Some(default_provider),
76 stage: RecoveryStage::ChooseProvider,
77 pending_provider_prompt: false,
78 selected,
79 secret_input: String::new(),
80 chatgpt_oauth: None,
81 }
82 }
83
84 pub fn missing_provider(provider: SetupProviderArg, pending_provider_prompt: bool) -> Self {
85 Self {
86 provider: Some(provider),
87 stage: RecoveryStage::MissingCredential,
88 pending_provider_prompt,
89 selected: 0,
90 secret_input: String::new(),
91 chatgpt_oauth: None,
92 }
93 }
94
95 pub fn acp_missing(pending_provider_prompt: bool) -> Self {
96 Self {
97 provider: None,
98 stage: RecoveryStage::AcpMissing,
99 pending_provider_prompt,
100 selected: 0,
101 secret_input: String::new(),
102 chatgpt_oauth: None,
103 }
104 }
105
106 pub fn login(provider: SetupProviderArg) -> Self {
107 Self {
108 provider: Some(provider),
109 stage: if provider == SetupProviderArg::ChatgptCodex {
110 RecoveryStage::MissingCredential
111 } else {
112 RecoveryStage::EnterKey
113 },
114 pending_provider_prompt: false,
115 selected: 0,
116 secret_input: String::new(),
117 chatgpt_oauth: None,
118 }
119 }
120
121 pub fn logout(provider: SetupProviderArg) -> Self {
122 Self {
123 provider: Some(provider),
124 stage: RecoveryStage::LogoutConfirm,
125 pending_provider_prompt: false,
126 selected: 0,
127 secret_input: String::new(),
128 chatgpt_oauth: None,
129 }
130 }
131
132 pub fn action_count(&self) -> usize {
133 match self.stage {
134 RecoveryStage::ChooseProvider => 3,
135 RecoveryStage::ModelSelection => self.app_model_selection_count(),
136 RecoveryStage::ModelConfigScope => 4,
137 RecoveryStage::MissingCredential => {
138 if self.provider == Some(SetupProviderArg::ChatgptCodex) {
139 6
140 } else {
141 5
142 }
143 }
144 RecoveryStage::EnterKey => 1,
145 RecoveryStage::ConfirmStore | RecoveryStage::LogoutConfirm => 3,
146 RecoveryStage::Instructions => 2,
147 RecoveryStage::ChatGptOAuthRequesting | RecoveryStage::ChatGptOAuthPasteRedirect => 1,
148 RecoveryStage::ChatGptOAuthPolling => self
149 .chatgpt_oauth
150 .as_ref()
151 .filter(|oauth| oauth.method == ChatGptOAuthMethod::Browser)
152 .map_or(1, |_| 2),
153 RecoveryStage::ChatGptOAuthFailed => 3,
154 RecoveryStage::AcpMissing => 4,
155 }
156 }
157
158 fn app_model_selection_count(&self) -> usize {
159 self.provider
160 .map(setup_model_options)
161 .map(|options| options.len())
162 .unwrap_or(0)
163 }
164}
165
166#[derive(Clone, Copy, Debug, Eq, PartialEq)]
168pub enum ChatGptOAuthMethod {
169 Browser,
171 DeviceCode,
173}
174
175#[derive(Clone, Eq, PartialEq)]
177pub struct ChatGptOAuthRecovery {
178 pub method: ChatGptOAuthMethod,
180 pub authorization_url: Option<String>,
182 pub code: Option<auth::ChatGptCodexDeviceCode>,
185 pub next_poll_tick: u64,
187 pub expires_at_tick: u64,
189 pub status: String,
191}
192
193impl std::fmt::Debug for ChatGptOAuthRecovery {
194 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195 formatter
196 .debug_struct("ChatGptOAuthRecovery")
197 .field("method", &self.method)
198 .field(
199 "authorization_url",
200 &self.authorization_url.as_ref().map(|_| "[redacted]"),
201 )
202 .field("code", &self.code)
203 .field("next_poll_tick", &self.next_poll_tick)
204 .field("expires_at_tick", &self.expires_at_tick)
205 .field("status", &self.status)
206 .finish()
207 }
208}
209
210#[derive(Clone, Copy, Debug, Eq, PartialEq)]
212pub enum RecoveryStage {
213 ChooseProvider,
215 ModelConfigScope,
217 ModelSelection,
219 MissingCredential,
221 EnterKey,
223 ConfirmStore,
225 Instructions,
227 ChatGptOAuthRequesting,
229 ChatGptOAuthPolling,
231 ChatGptOAuthPasteRedirect,
233 ChatGptOAuthFailed,
235 LogoutConfirm,
237 AcpMissing,
239}
240
241impl RecoveryStage {
242 pub fn label(self) -> &'static str {
243 match self {
244 RecoveryStage::ChooseProvider => "choose provider",
245 RecoveryStage::ModelSelection => "choose model",
246 RecoveryStage::ModelConfigScope => "model scope",
247 RecoveryStage::MissingCredential => "authentication required",
248 RecoveryStage::EnterKey => "credential entry",
249 RecoveryStage::ConfirmStore => "credential scope",
250 RecoveryStage::Instructions => "setup instructions",
251 RecoveryStage::ChatGptOAuthRequesting => "starting OAuth",
252 RecoveryStage::ChatGptOAuthPolling => "OAuth in progress",
253 RecoveryStage::ChatGptOAuthPasteRedirect => "paste redirect",
254 RecoveryStage::ChatGptOAuthFailed => "OAuth failed",
255 RecoveryStage::LogoutConfirm => "remove credential",
256 RecoveryStage::AcpMissing => "ACP setup required",
257 }
258 }
259}
260
261#[derive(Clone, Copy, Debug)]
265pub struct ChatGptOAuthDriver {
266 pub start_browser_login: fn() -> Result<auth::ChatGptCodexBrowserLogin, auth::AuthError>,
267 pub open_browser: fn(&str) -> Result<(), auth::AuthError>,
268 pub poll_browser_login:
269 fn(&mut auth::ChatGptCodexBrowserLogin) -> Result<auth::ChatGptCodexBrowserPoll, auth::AuthError>,
270 pub complete_browser_redirect:
271 fn(&auth::ChatGptCodexBrowserLogin, &str) -> Result<auth::ChatGptCodexCredentials, auth::AuthError>,
272 pub request_device_code: fn() -> Result<auth::ChatGptCodexDeviceCode, auth::AuthError>,
273 pub poll_device_code_once:
274 fn(&auth::ChatGptCodexDeviceCode) -> Result<auth::ChatGptCodexDevicePoll, auth::AuthError>,
275 pub write_credentials: fn(&auth::ChatGptCodexCredentials) -> Result<(), auth::AuthError>,
276}
277
278impl Default for ChatGptOAuthDriver {
279 fn default() -> Self {
280 Self {
281 start_browser_login: auth::start_chatgpt_codex_browser_login,
282 open_browser: auth::open_chatgpt_codex_authorization_url,
283 poll_browser_login: auth::poll_chatgpt_codex_browser_login_once,
284 complete_browser_redirect: auth::ChatGptCodexBrowserLogin::complete_redirect,
285 request_device_code: auth::request_chatgpt_codex_device_code,
286 poll_device_code_once: auth::poll_chatgpt_codex_device_code_once,
287 write_credentials: auth::write_chatgpt_codex_credentials,
288 }
289 }
290}
291
292#[derive(Clone, Copy, Debug, Eq, PartialEq)]
294pub struct PendingSetupReasoningEffort {
295 pub provider: SetupProviderArg,
296 pub scope: CredentialScope,
297}
298
299pub fn provider_for_model(model: &str) -> SetupProviderArg {
300 if opencode::is_zen_model_id(model) {
301 SetupProviderArg::OpencodeZen
302 } else if opencode::is_go_model_id(model) {
303 SetupProviderArg::OpencodeGo
304 } else if codex::is_model_id(model) {
305 SetupProviderArg::ChatgptCodex
306 } else {
307 SetupProviderArg::Umans
308 }
309}
310
311pub fn provider_authenticated(provider: SetupProviderArg, cwd: &std::path::Path) -> bool {
312 match provider {
313 SetupProviderArg::ChatgptCodex => chatgpt_codex_auth_available_locally(),
314 _ => match provider.api_key_env_var() {
315 Some(env_var) => auth::credential_source(env_var, cwd).is_some(),
316 _ => false,
317 },
318 }
319}
320
321pub fn chatgpt_codex_auth_available_locally() -> bool {
322 if let Ok(token) = std::env::var(auth::CHATGPT_CODEX_ACCESS_TOKEN_ENV)
323 && !token.trim().is_empty()
324 {
325 return auth::chatgpt_account_id_from_jwt(&token).is_ok();
326 }
327
328 matches!(
329 auth::read_chatgpt_codex_credentials(),
330 Ok(Some(credentials))
331 if !credentials.access_token.trim().is_empty()
332 && !credentials.refresh_token.trim().is_empty()
333 && !credentials.account_id.trim().is_empty()
334 )
335}
336
337pub fn selected_provider_missing(app: &App) -> Option<FirstRunRecovery> {
338 if app.model.trim().is_empty() {
339 return Some(FirstRunRecovery::setup(SetupProviderArg::ChatgptCodex));
340 }
341
342 if let Some(acp_name) = crate::acp::config::parse_model_id(&app.model) {
343 if app.cli.acp_agents.contains_key(acp_name) {
344 return None;
345 }
346 return Some(FirstRunRecovery::acp_missing(true));
347 }
348
349 let provider = provider_for_model(&app.model);
350 if !provider_authenticated(provider, &app.cwd) {
351 Some(FirstRunRecovery::missing_provider(provider, true))
352 } else {
353 None
354 }
355}
356
357pub fn handle_first_run_key(app: &mut App, key: KeyEvent) -> Option<Msg> {
358 let recovery = app.first_run_recovery.as_mut()?;
359
360 if recovery.stage == RecoveryStage::EnterKey || recovery.stage == RecoveryStage::ChatGptOAuthPasteRedirect {
361 match key.code {
362 KeyCode::Esc => {
363 recovery.secret_input.clear();
364 recovery.stage = if recovery.stage == RecoveryStage::ChatGptOAuthPasteRedirect {
365 RecoveryStage::ChatGptOAuthPolling
366 } else {
367 RecoveryStage::MissingCredential
368 };
369 recovery.selected = 0;
370 }
371 KeyCode::Backspace => {
372 recovery.secret_input.pop();
373 }
374 KeyCode::Enter => {
375 if recovery.secret_input.trim().is_empty() {
376 app.transcript.push(Entry::Error {
377 text: if recovery.stage == RecoveryStage::ChatGptOAuthPasteRedirect {
378 String::from("paste the full ChatGPT redirect URL or press Esc to cancel")
379 } else {
380 String::from("API key cannot be empty")
381 },
382 });
383 } else if recovery.stage == RecoveryStage::ChatGptOAuthPasteRedirect {
384 let redirect = recovery.secret_input.clone();
385 recovery.secret_input.clear();
386 complete_chatgpt_oauth_redirect(app, &redirect);
387 } else {
388 recovery.stage = RecoveryStage::ConfirmStore;
389 recovery.selected = 0;
390 }
391 }
392 KeyCode::Char(ch) => recovery.secret_input.push(ch),
393 _ => {}
394 }
395 return None;
396 }
397
398 if matches!(
399 recovery.stage,
400 RecoveryStage::ChatGptOAuthRequesting | RecoveryStage::ChatGptOAuthPolling
401 ) && key.code == KeyCode::Esc
402 {
403 recovery.stage = RecoveryStage::MissingCredential;
404 recovery.selected = 0;
405 recovery.chatgpt_oauth = None;
406 app.chatgpt_browser_login = None;
407 return None;
408 }
409
410 match key.code {
411 KeyCode::Esc => {
412 app.first_run_recovery = None;
413 None
414 }
415 KeyCode::Up => {
416 recovery.selected = recovery.selected.saturating_sub(1);
417 None
418 }
419 KeyCode::Down => {
420 let max = recovery.action_count().saturating_sub(1);
421 recovery.selected = (recovery.selected + 1).min(max);
422 None
423 }
424 KeyCode::Enter => accept_recovery_action(app),
425 _ => None,
426 }
427}
428
429pub fn accept_recovery_action(app: &mut App) -> Option<Msg> {
430 let recovery = app.first_run_recovery.clone()?;
431
432 match recovery.stage {
433 RecoveryStage::ChooseProvider => {
434 let Some(provider) = first_run_provider(recovery.selected) else {
435 app.first_run_recovery = Some(FirstRunRecovery {
436 provider: None,
437 stage: RecoveryStage::Instructions,
438 pending_provider_prompt: recovery.pending_provider_prompt,
439 selected: 0,
440 secret_input: String::new(),
441 chatgpt_oauth: None,
442 });
443 return None;
444 };
445 let stage = if provider_authenticated(provider, &app.cwd) {
446 RecoveryStage::ModelSelection
447 } else {
448 RecoveryStage::MissingCredential
449 };
450 app.first_run_recovery = Some(FirstRunRecovery {
451 provider: Some(provider),
452 stage,
453 pending_provider_prompt: recovery.pending_provider_prompt,
454 selected: 0,
455 secret_input: String::new(),
456 chatgpt_oauth: None,
457 });
458 }
459 RecoveryStage::ModelSelection => select_setup_model(app, &recovery),
460 RecoveryStage::ModelConfigScope => configure_setup_model_scope(app, &recovery),
461 RecoveryStage::MissingCredential if recovery.provider == Some(SetupProviderArg::ChatgptCodex) => {
462 match recovery.selected {
463 0 => start_chatgpt_browser_oauth_recovery(app),
464 1 => start_chatgpt_device_oauth_recovery(app),
465 2 => {
466 app.first_run_recovery = None;
467 open_model_picker(app);
468 }
469 3 => {
470 if let Some(active) = app.first_run_recovery.as_mut() {
471 active.stage = RecoveryStage::Instructions;
472 active.selected = 0;
473 active.chatgpt_oauth = None;
474 }
475 }
476 4 => {
477 if recovery.pending_provider_prompt {
478 app.transcript.push(Entry::Status {
479 text: String::from(
480 "setup required before submitting this ChatGPT Codex prompt; start OAuth login or switch model",
481 ),
482 });
483 } else {
484 app.first_run_recovery = None;
485 app.transcript
486 .push(Entry::Status { text: String::from("setup skipped") });
487 }
488 }
489 5 => {
490 app.quit = true;
491 return Some(Msg::Quit);
492 }
493 _ => {}
494 }
495 }
496 RecoveryStage::MissingCredential => match recovery.selected {
497 0 => {
498 if let Some(active) = app.first_run_recovery.as_mut() {
499 active.stage = RecoveryStage::EnterKey;
500 active.selected = 0;
501 active.secret_input.clear();
502 }
503 }
504 1 => {
505 if recovery.provider == Some(SetupProviderArg::ChatgptCodex) {
506 if let Some(active) = app.first_run_recovery.as_mut() {
507 active.stage = RecoveryStage::Instructions;
508 active.selected = 0;
509 }
510 return None;
511 }
512 app.first_run_recovery = None;
513 open_model_picker(app);
514 }
515 2 => {
516 if recovery.provider == Some(SetupProviderArg::ChatgptCodex) {
517 if recovery.pending_provider_prompt {
518 app.transcript.push(Entry::Status {
519 text: String::from(
520 "setup required before submitting this ChatGPT Codex prompt; start ChatGPT OAuth login or switch model",
521 ),
522 });
523 } else {
524 app.first_run_recovery = None;
525 app.transcript
526 .push(Entry::Status { text: String::from("setup skipped") });
527 }
528 return None;
529 }
530 if let Some(active) = app.first_run_recovery.as_mut() {
531 active.stage = RecoveryStage::Instructions;
532 active.selected = 0;
533 }
534 }
535 3 => {
536 if recovery.provider == Some(SetupProviderArg::ChatgptCodex) {
537 app.quit = true;
538 return Some(Msg::Quit);
539 }
540 if recovery.pending_provider_prompt {
541 app.transcript.push(Entry::Status {
542 text: String::from(
543 "setup required before submitting this provider-backed prompt; enter a key or switch model",
544 ),
545 });
546 } else {
547 app.first_run_recovery = None;
548 app.transcript
549 .push(Entry::Status { text: String::from("setup skipped") });
550 }
551 }
552 4 => {
553 app.quit = true;
554 return Some(Msg::Quit);
555 }
556 _ => {}
557 },
558 RecoveryStage::ConfirmStore => store_recovery_credential(app, &recovery),
559 RecoveryStage::Instructions => match recovery.selected {
560 0 => {
561 if let Some(active) = app.first_run_recovery.as_mut() {
562 active.stage = if active.provider.is_none() {
563 RecoveryStage::ChooseProvider
564 } else {
565 RecoveryStage::MissingCredential
566 };
567 active.selected = 0;
568 }
569 }
570 1 => app.first_run_recovery = None,
571 _ => {}
572 },
573 RecoveryStage::ChatGptOAuthRequesting => {}
574 RecoveryStage::ChatGptOAuthPolling => {
575 if recovery
576 .chatgpt_oauth
577 .as_ref()
578 .is_some_and(|oauth| oauth.method == ChatGptOAuthMethod::Browser && recovery.selected == 1)
579 {
580 if let Some(active) = app.first_run_recovery.as_mut() {
581 active.stage = RecoveryStage::ChatGptOAuthPasteRedirect;
582 active.selected = 0;
583 active.secret_input.clear();
584 }
585 } else if let Some(active) = app.first_run_recovery.as_mut() {
586 active.stage = RecoveryStage::MissingCredential;
587 active.selected = 0;
588 active.chatgpt_oauth = None;
589 app.chatgpt_browser_login = None;
590 }
591 }
592 RecoveryStage::ChatGptOAuthPasteRedirect => {}
593 RecoveryStage::ChatGptOAuthFailed => match recovery.selected {
594 0 => match recovery.chatgpt_oauth.as_ref().map(|oauth| oauth.method) {
595 Some(ChatGptOAuthMethod::DeviceCode) => start_chatgpt_device_oauth_recovery(app),
596 _ => start_chatgpt_browser_oauth_recovery(app),
597 },
598 1 => {
599 start_chatgpt_device_oauth_recovery(app);
600 }
601 2 => {
602 if let Some(active) = app.first_run_recovery.as_mut() {
603 active.stage = RecoveryStage::MissingCredential;
604 active.selected = 0;
605 active.chatgpt_oauth = None;
606 }
607 app.chatgpt_browser_login = None;
608 }
609 _ => {}
610 },
611 RecoveryStage::LogoutConfirm => remove_recovery_credential(app, &recovery),
612 RecoveryStage::AcpMissing => match recovery.selected {
613 0 => {
614 app.first_run_recovery = None;
615 open_model_picker(app);
616 }
617 1 => {
618 app.transcript.push(Entry::Status {
619 text: String::from("ACP setup: run `thndrs acp list` or `thndrs acp registry` outside the TUI"),
620 });
621 }
622 2 => {
623 if recovery.pending_provider_prompt {
624 app.transcript.push(Entry::Status {
625 text: String::from(
626 "ACP agent config is required before submitting this prompt; switch model or configure ACP",
627 ),
628 });
629 } else {
630 app.first_run_recovery = None;
631 }
632 }
633 3 => {
634 app.quit = true;
635 return Some(Msg::Quit);
636 }
637 _ => {}
638 },
639 RecoveryStage::EnterKey => {}
640 }
641
642 None
643}
644
645pub fn configure_setup_model_scope(app: &mut App, recovery: &FirstRunRecovery) {
646 let Some(provider) = recovery.provider else {
647 app.first_run_recovery = None;
648 return;
649 };
650
651 match recovery.selected {
652 0 => {
653 if write_setup_model_config(app, provider, CredentialScope::Project).is_ok() {
654 after_setup_model_config(app, provider, CredentialScope::Project);
655 }
656 }
657 1 => {
658 if write_setup_model_config(app, provider, CredentialScope::Global).is_ok() {
659 after_setup_model_config(app, provider, CredentialScope::Global);
660 }
661 }
662 2 => {
663 app.transcript
664 .push(Entry::Status { text: String::from("model config skipped") });
665 advance_after_setup_model_config(app, provider);
666 }
667 _ => {
668 app.first_run_recovery = None;
669 app.transcript
670 .push(Entry::Status { text: String::from("setup skipped") });
671 }
672 }
673}
674
675pub fn after_setup_model_config(app: &mut App, provider: SetupProviderArg, scope: CredentialScope) {
676 if codex::supports_reasoning_effort(&app.model) {
677 app.first_run_recovery = None;
678 app.pending_setup_reasoning_effort = Some(PendingSetupReasoningEffort { provider, scope });
679 open_reasoning_effort_picker(app);
680 } else {
681 advance_after_setup_model_config(app, provider);
682 }
683}
684
685pub fn write_setup_model_config(app: &mut App, _provider: SetupProviderArg, scope: CredentialScope) -> io::Result<()> {
686 let model = app.model.trim();
687 if model.is_empty() {
688 let err = io::Error::new(io::ErrorKind::InvalidInput, "choose a model before saving setup");
689 app.transcript
690 .push(Entry::Error { text: format!("failed to save selected model to config: {err}") });
691 return Err(err);
692 }
693 let path = match scope {
694 CredentialScope::Global => match config::global_config_path() {
695 Some(path) => path,
696 None => {
697 let err = io::Error::new(io::ErrorKind::NotFound, "HOME is not available");
698 app.transcript
699 .push(Entry::Error { text: format!("failed to save selected model to global config: {err}") });
700 return Err(err);
701 }
702 },
703 CredentialScope::Project => config::project_config_path(&app.cwd),
704 };
705
706 match config::write_model_config(&path, model) {
707 Ok(()) => {
708 let display = match scope {
709 CredentialScope::Global => config::global_config_path_display(&path),
710 CredentialScope::Project => config::project_config_path_display(&path, &app.cwd),
711 };
712 app.transcript
713 .push(Entry::Status { text: format!("model: {model} (saved to {display})") });
714 Ok(())
715 }
716 Err(err) => {
717 app.transcript.push(Entry::Error {
718 text: format!("failed to save selected model to {} config: {err}", scope.label()),
719 });
720 Err(err)
721 }
722 }
723}
724
725pub fn advance_after_setup_model_config(app: &mut App, provider: SetupProviderArg) {
726 if provider_authenticated(provider, &app.cwd) {
727 app.first_run_recovery = None;
728 app.transcript.push(Entry::Status {
729 text: format!(
730 "setup saved for {}; thndrs will verify the credential on the first provider request",
731 provider.label()
732 ),
733 });
734 } else if provider == SetupProviderArg::ChatgptCodex {
735 app.first_run_recovery = Some(FirstRunRecovery::missing_provider(provider, false));
736 } else {
737 app.first_run_recovery = Some(FirstRunRecovery::login(provider));
738 }
739}
740
741pub fn setup_model_options(provider: SetupProviderArg) -> Vec<PickerItem> {
742 let mut options: Vec<PickerItem> = offline_model_picker_items()
743 .into_iter()
744 .filter(|item| provider_for_model(&item.label) == provider)
745 .collect();
746 if !options.iter().any(|item| item.label == provider.default_model()) {
747 options.insert(0, PickerItem::new(provider.default_model(), "provider setup model"));
748 }
749 options
750}
751
752pub fn start_chatgpt_browser_oauth_recovery(app: &mut App) {
754 let pending_provider_prompt = app
755 .first_run_recovery
756 .as_ref()
757 .is_some_and(|recovery| recovery.pending_provider_prompt);
758 if let Some(active) = app.first_run_recovery.as_mut() {
759 active.stage = RecoveryStage::ChatGptOAuthRequesting;
760 active.selected = 0;
761 active.chatgpt_oauth = None;
762 }
763 app.chatgpt_browser_login = None;
764
765 match (app.chatgpt_oauth_driver.start_browser_login)() {
766 Ok(login) => {
767 let authorization_url = login.authorization_url().to_string();
768 let status = match (app.chatgpt_oauth_driver.open_browser)(&authorization_url) {
769 Ok(()) => String::from("Browser opened. Waiting for the ChatGPT callback."),
770 Err(_) => String::from("Browser did not open; copy the authorization URL below."),
771 };
772 let expires_at_tick = app.ui_tick.wrapping_add(seconds_to_ticks(app, 5 * 60));
773 app.chatgpt_browser_login = Some(login);
774 app.first_run_recovery = Some(FirstRunRecovery {
775 provider: Some(SetupProviderArg::ChatgptCodex),
776 stage: RecoveryStage::ChatGptOAuthPolling,
777 pending_provider_prompt,
778 selected: 0,
779 secret_input: String::new(),
780 chatgpt_oauth: Some(ChatGptOAuthRecovery {
781 method: ChatGptOAuthMethod::Browser,
782 authorization_url: Some(authorization_url),
783 code: None,
784 next_poll_tick: app.ui_tick,
785 expires_at_tick,
786 status,
787 }),
788 });
789 }
790 Err(err) => set_chatgpt_oauth_failure(
791 app,
792 ChatGptOAuthMethod::Browser,
793 pending_provider_prompt,
794 format!(
795 "ChatGPT browser OAuth could not start: {}",
796 redact_auth_error(&err.to_string())
797 ),
798 ),
799 }
800}
801
802pub fn start_chatgpt_device_oauth_recovery(app: &mut App) {
804 let pending_provider_prompt = app
805 .first_run_recovery
806 .as_ref()
807 .is_some_and(|recovery| recovery.pending_provider_prompt);
808 if let Some(active) = app.first_run_recovery.as_mut() {
809 active.stage = RecoveryStage::ChatGptOAuthRequesting;
810 active.selected = 0;
811 active.chatgpt_oauth = None;
812 }
813 app.chatgpt_browser_login = None;
814
815 match (app.chatgpt_oauth_driver.request_device_code)() {
816 Ok(code) => {
817 let next_poll_tick = app
818 .ui_tick
819 .wrapping_add(seconds_to_ticks(app, code.interval.unwrap_or(5).max(1)));
820 let expires_at_tick = app
821 .ui_tick
822 .wrapping_add(seconds_to_ticks(app, code.expires_in.unwrap_or(900).max(1)));
823 app.first_run_recovery = Some(FirstRunRecovery {
824 provider: Some(SetupProviderArg::ChatgptCodex),
825 stage: RecoveryStage::ChatGptOAuthPolling,
826 pending_provider_prompt,
827 selected: 0,
828 secret_input: String::new(),
829 chatgpt_oauth: Some(ChatGptOAuthRecovery {
830 method: ChatGptOAuthMethod::DeviceCode,
831 authorization_url: None,
832 code: Some(code),
833 next_poll_tick,
834 expires_at_tick,
835 status: String::from("Waiting for ChatGPT authorization."),
836 }),
837 });
838 }
839 Err(err) => {
840 set_chatgpt_oauth_failure(
841 app,
842 ChatGptOAuthMethod::DeviceCode,
843 pending_provider_prompt,
844 format!(
845 "ChatGPT device-code login could not start: {}",
846 redact_auth_error(&err.to_string())
847 ),
848 );
849 }
850 }
851}
852
853fn set_chatgpt_oauth_failure(app: &mut App, method: ChatGptOAuthMethod, pending_provider_prompt: bool, status: String) {
854 app.chatgpt_browser_login = None;
855 app.first_run_recovery = Some(FirstRunRecovery {
856 provider: Some(SetupProviderArg::ChatgptCodex),
857 stage: RecoveryStage::ChatGptOAuthFailed,
858 pending_provider_prompt,
859 selected: 0,
860 secret_input: String::new(),
861 chatgpt_oauth: Some(ChatGptOAuthRecovery {
862 method,
863 authorization_url: None,
864 code: None,
865 next_poll_tick: app.ui_tick,
866 expires_at_tick: app.ui_tick,
867 status: status.clone(),
868 }),
869 });
870 app.transcript.push(Entry::Error { text: status });
871}
872
873fn finish_chatgpt_oauth(
874 app: &mut App, credentials: &auth::ChatGptCodexCredentials, pending_provider_prompt: bool,
875 method: ChatGptOAuthMethod,
876) {
877 match (app.chatgpt_oauth_driver.write_credentials)(credentials) {
878 Ok(()) => {
879 app.chatgpt_browser_login = None;
880 let needs_model = app.model.trim().is_empty();
881 app.transcript.push(Entry::Status {
882 text: String::from("chatgpt-codex OAuth credential stored in global auth store"),
883 });
884 if needs_model {
885 app.first_run_recovery = Some(FirstRunRecovery {
886 provider: Some(SetupProviderArg::ChatgptCodex),
887 stage: RecoveryStage::ModelSelection,
888 pending_provider_prompt,
889 selected: 0,
890 secret_input: String::new(),
891 chatgpt_oauth: None,
892 });
893 } else {
894 app.first_run_recovery = None;
895 }
896 }
897 Err(err) => set_chatgpt_oauth_failure(
898 app,
899 method,
900 pending_provider_prompt,
901 format!(
902 "ChatGPT OAuth credential write failed: {}",
903 redact_auth_error(&err.to_string())
904 ),
905 ),
906 }
907}
908
909fn complete_chatgpt_oauth_redirect(app: &mut App, redirect: &str) {
910 let pending_provider_prompt = app
911 .first_run_recovery
912 .as_ref()
913 .is_some_and(|recovery| recovery.pending_provider_prompt);
914 let result = app
915 .chatgpt_browser_login
916 .as_ref()
917 .ok_or_else(|| auth::AuthError::ChatGptCodex("browser OAuth session is no longer active".to_string()))
918 .and_then(|login| (app.chatgpt_oauth_driver.complete_browser_redirect)(login, redirect));
919 match result {
920 Ok(credentials) => {
921 finish_chatgpt_oauth(app, &credentials, pending_provider_prompt, ChatGptOAuthMethod::Browser)
922 }
923 Err(err) => {
924 let status = format!("ChatGPT redirect was rejected: {}", redact_auth_error(&err.to_string()));
925 if let Some(recovery) = app.first_run_recovery.as_mut() {
926 recovery.stage = RecoveryStage::ChatGptOAuthPolling;
927 recovery.selected = 0;
928 if let Some(oauth) = recovery.chatgpt_oauth.as_mut() {
929 oauth.status = status.clone();
930 }
931 }
932 app.transcript.push(Entry::Error { text: status });
933 }
934 }
935}
936
937pub fn poll_chatgpt_oauth_on_tick(app: &mut App) {
938 let tick_ms = app.cli.tick_rate_ms.max(1);
939 let Some(recovery) = app.first_run_recovery.as_ref() else {
940 return;
941 };
942 if recovery.stage != RecoveryStage::ChatGptOAuthPolling {
943 return;
944 }
945 let Some(oauth) = recovery.chatgpt_oauth.as_ref() else {
946 return;
947 };
948 let method = oauth.method;
949 let pending_provider_prompt = recovery.pending_provider_prompt;
950 let expires_at_tick = oauth.expires_at_tick;
951 let next_poll_tick = oauth.next_poll_tick;
952 if super::agent_lifecycle::now_or_after_deadline(app.ui_tick, expires_at_tick) {
953 set_chatgpt_oauth_failure(
954 app,
955 method,
956 pending_provider_prompt,
957 match method {
958 ChatGptOAuthMethod::Browser => String::from("ChatGPT browser OAuth callback expired."),
959 ChatGptOAuthMethod::DeviceCode => String::from("ChatGPT device-code login expired."),
960 },
961 );
962 return;
963 }
964 if method == ChatGptOAuthMethod::DeviceCode
965 && !super::agent_lifecycle::now_or_after_deadline(app.ui_tick, next_poll_tick)
966 {
967 return;
968 }
969
970 match method {
971 ChatGptOAuthMethod::Browser => {
972 let result = app
973 .chatgpt_browser_login
974 .as_mut()
975 .ok_or_else(|| auth::AuthError::ChatGptCodex("browser OAuth session is no longer active".to_string()))
976 .and_then(|login| (app.chatgpt_oauth_driver.poll_browser_login)(login));
977 match result {
978 Ok(auth::ChatGptCodexBrowserPoll::Pending) => {}
979 Ok(auth::ChatGptCodexBrowserPoll::Authorized(credentials)) => {
980 finish_chatgpt_oauth(app, &credentials, pending_provider_prompt, method);
981 }
982 Err(err) => set_chatgpt_oauth_failure(
983 app,
984 method,
985 pending_provider_prompt,
986 format!("ChatGPT browser OAuth failed: {}", redact_auth_error(&err.to_string())),
987 ),
988 }
989 }
990 ChatGptOAuthMethod::DeviceCode => {
991 let Some(code) = app
992 .first_run_recovery
993 .as_ref()
994 .and_then(|recovery| recovery.chatgpt_oauth.as_ref())
995 .and_then(|oauth| oauth.code.as_ref())
996 .cloned()
997 else {
998 set_chatgpt_oauth_failure(
999 app,
1000 method,
1001 pending_provider_prompt,
1002 String::from("ChatGPT device-code state is unavailable."),
1003 );
1004 return;
1005 };
1006 match (app.chatgpt_oauth_driver.poll_device_code_once)(&code) {
1007 Ok(auth::ChatGptCodexDevicePoll::Pending) => {
1008 if let Some(oauth) = app
1009 .first_run_recovery
1010 .as_mut()
1011 .and_then(|recovery| recovery.chatgpt_oauth.as_mut())
1012 {
1013 oauth.status = String::from("Waiting for ChatGPT authorization.");
1014 oauth.next_poll_tick = app
1015 .ui_tick
1016 .wrapping_add(seconds_to_ticks_for_ms(tick_ms, code.interval.unwrap_or(5).max(1)));
1017 }
1018 }
1019 Ok(auth::ChatGptCodexDevicePoll::SlowDown) => {
1020 if let Some(oauth) = app
1021 .first_run_recovery
1022 .as_mut()
1023 .and_then(|recovery| recovery.chatgpt_oauth.as_mut())
1024 {
1025 oauth.status = String::from("ChatGPT asked the client to slow down; waiting.");
1026 oauth.next_poll_tick = app.ui_tick.wrapping_add(seconds_to_ticks_for_ms(
1027 tick_ms,
1028 code.interval.unwrap_or(5).max(1).saturating_add(5),
1029 ));
1030 }
1031 }
1032 Ok(auth::ChatGptCodexDevicePoll::Authorized(credentials)) => {
1033 finish_chatgpt_oauth(app, &credentials, pending_provider_prompt, method);
1034 }
1035 Err(err) => set_chatgpt_oauth_failure(
1036 app,
1037 method,
1038 pending_provider_prompt,
1039 format!(
1040 "ChatGPT device-code polling failed: {}",
1041 redact_auth_error(&err.to_string())
1042 ),
1043 ),
1044 }
1045 }
1046 }
1047}
1048
1049pub fn seconds_to_ticks(app: &App, seconds: u64) -> u64 {
1050 seconds_to_ticks_for_ms(app.cli.tick_rate_ms.max(1), seconds)
1051}
1052
1053pub fn seconds_to_ticks_for_ms(tick_ms: u64, seconds: u64) -> u64 {
1054 seconds.saturating_mul(1000).div_ceil(tick_ms).max(1)
1055}
1056
1057pub fn redact_auth_error(message: &str) -> String {
1058 let mut redacted = Vec::new();
1059 for part in message.split_whitespace() {
1060 if part.len() >= 24
1061 || part.contains("access_token")
1062 || part.contains("refresh_token")
1063 || part.contains("device_auth_id")
1064 || part.contains("device_code")
1065 {
1066 redacted.push("[redacted]");
1067 } else {
1068 redacted.push(part);
1069 }
1070 }
1071 redacted.join(" ")
1072}
1073
1074pub fn selected_scope(selected: usize) -> Option<CredentialScope> {
1075 match selected {
1076 0 => Some(CredentialScope::Global),
1077 1 => Some(CredentialScope::Project),
1078 _ => None,
1079 }
1080}
1081
1082pub fn store_recovery_credential(app: &mut App, recovery: &FirstRunRecovery) {
1083 let Some(provider) = recovery.provider else {
1084 app.first_run_recovery = None;
1085 return;
1086 };
1087 let Some(scope) = selected_scope(recovery.selected) else {
1088 app.first_run_recovery = Some(FirstRunRecovery::missing_provider(
1089 provider,
1090 recovery.pending_provider_prompt,
1091 ));
1092 return;
1093 };
1094
1095 let key = recovery.secret_input.trim();
1096 let path = match crate::cli::commands::auth::credential_path(scope, &app.cwd) {
1097 Ok(path) => path,
1098 Err(err) => {
1099 app.transcript
1100 .push(Entry::Error { text: format!("credential store unavailable: {err}") });
1101 return;
1102 }
1103 };
1104
1105 let Some(env_var) = provider.api_key_env_var() else {
1106 app.first_run_recovery = Some(FirstRunRecovery::missing_provider(
1107 provider,
1108 recovery.pending_provider_prompt,
1109 ));
1110 app.transcript
1111 .push(Entry::Error { text: String::from("ChatGPT Codex uses OAuth login, not API-key storage") });
1112 return;
1113 };
1114 match auth::set_credential(&path, env_var, key) {
1115 Ok(()) => {
1116 if scope == CredentialScope::Project
1117 && let Err(err) = auth::ensure_git_exclude(&app.cwd)
1118 {
1119 app.transcript
1120 .push(Entry::Error { text: format!("git exclude update failed: {err}") });
1121 }
1122 app.transcript
1123 .push(Entry::Status { text: format!("{} credential stored in {}", provider.label(), scope.label()) });
1124 if app.model.trim().is_empty() {
1125 app.first_run_recovery = Some(FirstRunRecovery {
1126 provider: Some(provider),
1127 stage: RecoveryStage::ModelSelection,
1128 pending_provider_prompt: recovery.pending_provider_prompt,
1129 selected: 0,
1130 secret_input: String::new(),
1131 chatgpt_oauth: None,
1132 });
1133 } else {
1134 app.first_run_recovery = None;
1135 }
1136 }
1137 Err(err) => app
1138 .transcript
1139 .push(Entry::Error { text: format!("credential write failed: {err}") }),
1140 }
1141}
1142
1143pub fn remove_recovery_credential(app: &mut App, recovery: &FirstRunRecovery) {
1144 let Some(provider) = recovery.provider else {
1145 app.first_run_recovery = None;
1146 return;
1147 };
1148 let Some(scope) = selected_scope(recovery.selected) else {
1149 app.first_run_recovery = None;
1150 app.transcript
1151 .push(Entry::Status { text: String::from("logout cancelled") });
1152 return;
1153 };
1154 let path = match crate::cli::commands::auth::credential_path(scope, &app.cwd) {
1155 Ok(path) => path,
1156 Err(err) => {
1157 app.transcript
1158 .push(Entry::Error { text: format!("credential store unavailable: {err}") });
1159 return;
1160 }
1161 };
1162 let Some(env_var) = provider.api_key_env_var() else {
1163 app.first_run_recovery = None;
1164 app.transcript
1165 .push(Entry::Error { text: String::from("ChatGPT Codex credentials are stored in ~/.thndrs/auth.json") });
1166 return;
1167 };
1168 match auth::remove_credential(&path, env_var) {
1169 Ok(()) => {
1170 app.first_run_recovery = None;
1171 app.transcript.push(Entry::Status {
1172 text: format!("{} credential removed from {}", provider.label(), scope.label()),
1173 });
1174 }
1175 Err(err) => app
1176 .transcript
1177 .push(Entry::Error { text: format!("credential remove failed: {err}") }),
1178 }
1179}
1180
1181fn select_setup_model(app: &mut App, recovery: &FirstRunRecovery) {
1182 let Some(provider) = recovery.provider else {
1183 app.first_run_recovery = Some(FirstRunRecovery::setup(SetupProviderArg::ChatgptCodex));
1184 return;
1185 };
1186 let options = setup_model_options(provider);
1187 let Some(model) = options.get(recovery.selected).map(|item| item.label.clone()) else {
1188 return;
1189 };
1190 app.model = model.clone();
1191 app.cli.model = model.clone();
1192 app.transcript
1193 .push(Entry::Status { text: format!("model selected: {model}") });
1194 app.first_run_recovery = Some(FirstRunRecovery {
1195 provider: Some(provider),
1196 stage: RecoveryStage::ModelConfigScope,
1197 pending_provider_prompt: recovery.pending_provider_prompt,
1198 selected: 0,
1199 secret_input: String::new(),
1200 chatgpt_oauth: None,
1201 });
1202}
1203
1204fn first_run_provider(selected: usize) -> Option<SetupProviderArg> {
1205 match selected {
1206 0 => Some(SetupProviderArg::ChatgptCodex),
1207 1 => Some(SetupProviderArg::Umans),
1208 _ => None,
1209 }
1210}