use anstyle::{AnsiColor, Color, Effects, Style as AnsiStyle};
use anyhow::Result;
use vtcode_auth::{AuthStatus, OpenAIChatGptAuthStatus, OpenAIResolvedAuthSource};
use vtcode_core::config::api_keys::{ApiKeySources, get_api_key};
use vtcode_core::config::types::UiSurfacePreference;
use vtcode_core::copilot::{
COPILOT_AUTH_DOC_PATH, CopilotAuthEvent, CopilotAuthStatus, CopilotAuthStatusKind,
login_with_events, logout_with_events, probe_auth_status,
};
use vtcode_core::hooks::SessionEndReason;
use vtcode_core::llm::factory::{ProviderConfig, create_provider_with_config};
use vtcode_core::utils::ansi::{AnsiRenderer, MessageStyle};
use vtcode_core::utils::ansi_codes::notify_attention;
use vtcode_tui::app::{InlineListItem, InlineListSelection, WizardModalMode, WizardStep};
use super::{SlashCommandContext, SlashCommandControl, ui};
use crate::agent::runloop::slash_commands::OAuthProviderAction;
use crate::agent::runloop::ui::build_inline_header_context;
use crate::agent::runloop::unified::external_url_guard::{
ExternalUrlGuardContext, ExternalUrlOpenOutcome, request_external_url_open,
};
use crate::agent::runloop::unified::wizard_modal::{
WizardModalOutcome, show_wizard_modal_and_wait,
};
use crate::cli::auth::{
COPILOT_PROVIDER, OPENAI_PROVIDER, OPENROUTER_PROVIDER, begin_openai_login,
begin_openrouter_login, clear_openai_login, clear_openrouter_login,
complete_openai_login_from_manual_future, complete_openai_login_with_manual_future,
complete_openrouter_login_with_tui_cancel, is_oauth_flow_cancelled, oauth_flow_cancelled_error,
openai_auth_status, openai_manual_placeholder, openrouter_auth_status, prepare_openai_login,
prepare_openrouter_login, refresh_openai_login, should_prompt_manual_openai_input,
supports_auth_provider,
};
const OAUTH_PROVIDER_PREFIX: &str = "oauth-provider:";
const OAUTH_PROVIDER_BACK: &str = "oauth-provider:back";
const OPENAI_MANUAL_PROMPT_ID: &str = "openai_manual_callback";
pub(crate) async fn handle_start_oauth_provider_picker(
mut ctx: SlashCommandContext<'_>,
action: OAuthProviderAction,
) -> Result<SlashCommandControl> {
let activity = match action {
OAuthProviderAction::Login => "opening authentication login",
OAuthProviderAction::Logout => "opening authentication logout",
OAuthProviderAction::Refresh => "opening authentication refresh",
};
if !ui::ensure_selection_ui_available(&mut ctx, activity)? {
return Ok(SlashCommandControl::Continue);
}
show_oauth_provider_modal(&mut ctx, action).await?;
let Some(selection) = ui::wait_for_list_modal_selection(&mut ctx).await else {
return Ok(SlashCommandControl::Continue);
};
let InlineListSelection::ConfigAction(action_key) = selection else {
return Ok(SlashCommandControl::Continue);
};
if action_key == OAUTH_PROVIDER_BACK {
return Ok(SlashCommandControl::Continue);
}
let Some(provider) = action_key.strip_prefix(OAUTH_PROVIDER_PREFIX) else {
return Ok(SlashCommandControl::Continue);
};
match action {
OAuthProviderAction::Login => handle_oauth_login(ctx, provider.to_string()).await,
OAuthProviderAction::Logout => handle_oauth_logout(ctx, provider.to_string()).await,
OAuthProviderAction::Refresh => handle_refresh_oauth(ctx, provider.to_string()).await,
}
}
pub(crate) async fn handle_oauth_login(
mut ctx: SlashCommandContext<'_>,
provider: String,
) -> Result<SlashCommandControl> {
let provider = provider.trim().to_ascii_lowercase();
if !ensure_supported_provider(ctx.renderer, &provider, "login")? {
return Ok(SlashCommandControl::Continue);
}
let vt_cfg = ctx.vt_cfg.as_ref();
match provider.as_str() {
COPILOT_PROVIDER => {
ctx.renderer.line(
MessageStyle::Info,
"Starting GitHub Copilot authentication via the official `copilot` CLI...",
)?;
render_copilot_auth_intro(ctx.renderer, CopilotAuthAction::Login)?;
let workspace = ctx.config.workspace.clone();
let auth_cfg = ctx
.vt_cfg
.as_ref()
.map(|cfg| cfg.auth.copilot.clone())
.unwrap_or_default();
login_with_events(&auth_cfg, &workspace, |event| {
render_copilot_auth_event(ctx.renderer, event)
})
.await?;
sync_copilot_runtime_if_active(&mut ctx).await?;
ctx.renderer.line(
MessageStyle::Info,
"Successfully authenticated with GitHub Copilot.",
)?;
if ctx.config.provider.eq_ignore_ascii_case(COPILOT_PROVIDER) {
ctx.renderer.line(
MessageStyle::Output,
"Switched the current session to GitHub Copilot.",
)?;
}
}
OPENROUTER_PROVIDER => {
ctx.renderer.line(
MessageStyle::Info,
"Starting OpenRouter OAuth authentication...",
)?;
let prepared = prepare_openrouter_login(vt_cfg)?;
let auth_url = prepared.auth_url.clone();
if let Some(control) = open_browser_with_guidance(&mut ctx, &auth_url).await? {
return Ok(control);
}
let started = begin_openrouter_login(prepared).await?;
ctx.renderer.line(
MessageStyle::Info,
"Waiting for OpenRouter OAuth callback...",
)?;
let api_key = match complete_openrouter_login_with_tui_cancel(
started,
ctx.ctrl_c_state,
ctx.ctrl_c_notify,
)
.await
{
Ok(api_key) => api_key,
Err(err) if is_oauth_flow_cancelled(&err) => {
if ctx.ctrl_c_state.is_exit_requested() {
return Ok(SlashCommandControl::BreakWithReason(SessionEndReason::Exit));
}
ctx.renderer.line(
MessageStyle::Info,
"OpenRouter OAuth authentication cancelled.",
)?;
return Ok(SlashCommandControl::Continue);
}
Err(err) => return Err(err),
};
ctx.renderer.line(
MessageStyle::Info,
"Successfully authenticated with OpenRouter.",
)?;
ctx.renderer.line(
MessageStyle::Output,
"Stored the OAuth token using your configured credential storage mode.",
)?;
ctx.renderer.line(
MessageStyle::Output,
&format!("Key preview: {}...", &api_key[..api_key.len().min(8)]),
)?;
}
OPENAI_PROVIDER => {
ctx.renderer.line(
MessageStyle::Info,
"Starting OpenAI ChatGPT authentication...",
)?;
let prepared = prepare_openai_login(vt_cfg)?;
let auth_url = prepared.auth_url.clone();
let callback_port = prepared.callback_port;
let started = begin_openai_login(prepared.clone()).await;
if let Some(control) = open_browser_with_guidance(&mut ctx, &auth_url).await? {
return Ok(control);
}
ctx.renderer
.line(MessageStyle::Info, "Waiting for OpenAI OAuth callback...")?;
let login_result = match started {
Ok(started) => {
let manual_input =
prompt_openai_manual_callback_input(&mut ctx, callback_port, &auth_url);
complete_openai_login_with_manual_future(started, Some(manual_input)).await
}
Err(err) if should_prompt_manual_openai_input(&err) => {
ctx.renderer.line(
MessageStyle::Info,
"Local callback listener is unavailable. Waiting for manual redirect input...",
)?;
let manual_input =
prompt_openai_manual_callback_input(&mut ctx, callback_port, &auth_url);
complete_openai_login_from_manual_future(prepared, manual_input).await
}
Err(err) => Err(err),
};
ctx.handle.close_modal();
ctx.handle.force_redraw();
let session = match login_result {
Ok(session) => session,
Err(err) if is_oauth_flow_cancelled(&err) => {
if ctx.ctrl_c_state.is_exit_requested() {
return Ok(SlashCommandControl::BreakWithReason(SessionEndReason::Exit));
}
ctx.renderer.line(
MessageStyle::Info,
"OpenAI ChatGPT authentication cancelled.",
)?;
return Ok(SlashCommandControl::Continue);
}
Err(err) => return Err(err),
};
sync_openai_runtime_if_active(&mut ctx).await?;
ctx.renderer.line(
MessageStyle::Info,
"Successfully authenticated with OpenAI via ChatGPT.",
)?;
if ctx.config.provider.eq_ignore_ascii_case(OPENAI_PROVIDER)
&& ctx.config.openai_chatgpt_auth.is_some()
{
ctx.renderer.line(
MessageStyle::Output,
"Switched the current session to OpenAI (ChatGPT).",
)?;
}
if let Some(email) = session.email.as_deref() {
ctx.renderer
.line(MessageStyle::Output, &format!("Account: {}", email))?;
}
if let Some(plan) = session.plan.as_deref() {
ctx.renderer
.line(MessageStyle::Output, &format!("Plan: {}", plan))?;
}
}
_ => {}
}
Ok(SlashCommandControl::Continue)
}
pub(crate) async fn handle_oauth_logout(
mut ctx: SlashCommandContext<'_>,
provider: String,
) -> Result<SlashCommandControl> {
let provider = provider.trim().to_ascii_lowercase();
if !ensure_supported_provider(ctx.renderer, &provider, "logout")? {
return Ok(SlashCommandControl::Continue);
}
let vt_cfg = ctx.vt_cfg.as_ref();
match provider.as_str() {
COPILOT_PROVIDER => {
ctx.renderer.line(
MessageStyle::Info,
"Starting GitHub Copilot logout via the official `copilot` CLI...",
)?;
render_copilot_auth_intro(ctx.renderer, CopilotAuthAction::Logout)?;
let auth_cfg = vt_cfg
.map(|cfg| cfg.auth.copilot.clone())
.unwrap_or_default();
logout_with_events(&auth_cfg, &ctx.config.workspace, |event| {
render_copilot_auth_event(ctx.renderer, event)
})
.await?;
sync_copilot_runtime_if_active(&mut ctx).await?;
ctx.renderer.line(
MessageStyle::Info,
"GitHub Copilot authentication cleared successfully.",
)?;
if ctx.config.provider.eq_ignore_ascii_case(COPILOT_PROVIDER) {
ctx.renderer.line(
MessageStyle::Output,
"The current GitHub Copilot session no longer has active credentials.",
)?;
}
}
OPENROUTER_PROVIDER => {
if matches!(
openrouter_auth_status(vt_cfg)?,
AuthStatus::NotAuthenticated
) {
if get_api_key(OPENROUTER_PROVIDER, &ApiKeySources::default()).is_ok() {
ctx.renderer.line(
MessageStyle::Info,
"OpenRouter OAuth token already cleared; using OPENROUTER_API_KEY.",
)?;
} else {
ctx.renderer.line(
MessageStyle::Info,
"No stored OpenRouter OAuth token to clear.",
)?;
}
return Ok(SlashCommandControl::Continue);
}
clear_openrouter_login(vt_cfg)?;
ctx.renderer.line(
MessageStyle::Info,
"OpenRouter OAuth token cleared successfully.",
)?;
}
OPENAI_PROVIDER => {
if matches!(
openai_auth_status(vt_cfg)?,
OpenAIChatGptAuthStatus::NotAuthenticated
) {
if get_api_key(OPENAI_PROVIDER, &ApiKeySources::default()).is_ok() {
ctx.renderer.line(
MessageStyle::Info,
"OpenAI ChatGPT session already cleared; using OPENAI_API_KEY.",
)?;
} else {
ctx.renderer.line(
MessageStyle::Info,
"No stored OpenAI ChatGPT session to clear.",
)?;
}
return Ok(SlashCommandControl::Continue);
}
clear_openai_login(vt_cfg)?;
sync_openai_runtime_if_active(&mut ctx).await?;
ctx.renderer.line(
MessageStyle::Info,
"OpenAI ChatGPT session cleared successfully.",
)?;
if ctx.config.provider.eq_ignore_ascii_case(OPENAI_PROVIDER) {
if ctx.config.api_key.trim().is_empty() {
ctx.renderer.line(
MessageStyle::Output,
"The current OpenAI session no longer has active credentials.",
)?;
} else {
ctx.renderer.line(
MessageStyle::Output,
"The current OpenAI session fell back to OPENAI_API_KEY.",
)?;
}
}
}
_ => {}
}
Ok(SlashCommandControl::Continue)
}
pub(crate) async fn handle_refresh_oauth(
mut ctx: SlashCommandContext<'_>,
provider: String,
) -> Result<SlashCommandControl> {
let provider = provider.trim().to_ascii_lowercase();
if !ensure_supported_provider(ctx.renderer, &provider, "refresh")? {
return Ok(SlashCommandControl::Continue);
}
match provider.as_str() {
COPILOT_PROVIDER => {
ctx.renderer.line(
MessageStyle::Info,
"GitHub Copilot does not expose a refresh-token flow. Use /login copilot to reconnect if needed.",
)?;
}
OPENAI_PROVIDER => {
ctx.renderer.line(
MessageStyle::Info,
"Refreshing the stored OpenAI ChatGPT session...",
)?;
let session = refresh_openai_login(ctx.vt_cfg.as_ref()).await?;
sync_openai_runtime_if_active(&mut ctx).await?;
ctx.renderer.line(
MessageStyle::Info,
"OpenAI ChatGPT session refreshed successfully.",
)?;
if let Some(email) = session.email.as_deref() {
ctx.renderer
.line(MessageStyle::Output, &format!("Account: {}", email))?;
}
if let Some(plan) = session.plan.as_deref() {
ctx.renderer
.line(MessageStyle::Output, &format!("Plan: {}", plan))?;
}
}
OPENROUTER_PROVIDER => {
ctx.renderer.line(
MessageStyle::Info,
"OpenRouter OAuth does not expose a refresh-token flow. Use /login openrouter to reconnect if needed.",
)?;
}
_ => {}
}
Ok(SlashCommandControl::Continue)
}
pub(crate) async fn handle_show_auth_status(
ctx: SlashCommandContext<'_>,
provider: Option<String>,
) -> Result<SlashCommandControl> {
let provider = provider.map(|value| value.trim().to_ascii_lowercase());
if let Some(provider_name) = provider.as_deref()
&& !supports_auth_provider(provider_name)
{
ctx.renderer.line(
MessageStyle::Error,
&format!(
"Authentication status not supported for provider: {}. Supported providers: openai, openrouter, copilot",
provider_name
),
)?;
return Ok(SlashCommandControl::Continue);
}
ctx.renderer
.line(MessageStyle::Info, "Authentication Status")?;
ctx.renderer.line(MessageStyle::Output, "")?;
let vt_cfg = ctx.vt_cfg.as_ref();
if provider.is_none() || provider.as_deref() == Some(OPENROUTER_PROVIDER) {
render_openrouter_auth_status(ctx.renderer, openrouter_auth_status(vt_cfg)?)?;
}
if provider.is_none() {
ctx.renderer.line(MessageStyle::Output, "")?;
}
if provider.is_none() || provider.as_deref() == Some(OPENAI_PROVIDER) {
render_openai_auth_status(ctx.renderer, openai_auth_status(vt_cfg)?)?;
render_openai_credential_overview(
ctx.renderer,
vt_cfg,
ctx.config.provider.eq_ignore_ascii_case(OPENAI_PROVIDER),
)?;
}
if provider.is_none() {
ctx.renderer.line(MessageStyle::Output, "")?;
}
if provider.is_none() || provider.as_deref() == Some(COPILOT_PROVIDER) {
let auth_cfg = vt_cfg
.map(|cfg| cfg.auth.copilot.clone())
.unwrap_or_default();
let status = probe_auth_status(&auth_cfg, Some(&ctx.config.workspace)).await;
render_copilot_auth_status(ctx.renderer, status)?;
}
if provider.is_none() {
ctx.renderer.line(MessageStyle::Output, "")?;
ctx.renderer.line(
MessageStyle::Output,
"Use /login, /logout, or /refresh-oauth to manage stored authentication.",
)?;
}
Ok(SlashCommandControl::Continue)
}
fn ensure_supported_provider(
renderer: &mut AnsiRenderer,
provider: &str,
action: &str,
) -> Result<bool> {
if supports_auth_provider(provider) {
return Ok(true);
}
renderer.line(
MessageStyle::Error,
&format!(
"Authentication {action} not supported for provider: {provider}. Supported providers: openai, openrouter, copilot"
),
)?;
Ok(false)
}
async fn open_browser_with_guidance(
ctx: &mut SlashCommandContext<'_>,
auth_url: &str,
) -> Result<Option<SlashCommandControl>> {
match request_external_url_open(
ExternalUrlGuardContext::new(ctx.handle, ctx.session, ctx.ctrl_c_state, ctx.ctrl_c_notify),
auth_url,
)
.await?
{
ExternalUrlOpenOutcome::Opened => {
ctx.renderer
.line(MessageStyle::Info, "Opening browser for authentication...")?;
ctx.renderer
.hyperlink_line(MessageStyle::Response, auth_url)?;
Ok(None)
}
ExternalUrlOpenOutcome::OpenFailed(err) => {
ctx.renderer
.line(MessageStyle::Info, "Opening browser for authentication...")?;
ctx.renderer
.hyperlink_line(MessageStyle::Response, auth_url)?;
ctx.renderer.line(
MessageStyle::Error,
&format!("Failed to open browser automatically: {}", err),
)?;
ctx.renderer.line(
MessageStyle::Info,
"Please open the URL manually in your browser.",
)?;
Ok(None)
}
ExternalUrlOpenOutcome::Cancelled => {
ctx.renderer
.line(MessageStyle::Info, "Cancelled opening authentication link.")?;
Ok(Some(SlashCommandControl::Continue))
}
ExternalUrlOpenOutcome::Exit => Ok(Some(SlashCommandControl::BreakWithReason(
SessionEndReason::Exit,
))),
ExternalUrlOpenOutcome::Unsupported => {
ctx.renderer.line(
MessageStyle::Error,
"Blocked unsupported authentication link target.",
)?;
Ok(Some(SlashCommandControl::Continue))
}
}
}
async fn prompt_openai_manual_callback_input(
ctx: &mut SlashCommandContext<'_>,
callback_port: u16,
auth_url: &str,
) -> Result<Option<String>> {
let step = WizardStep {
title: "Callback".to_string(),
question: format!(
"Waiting for browser callback. If it doesn't open automatically, copy this URL:\n\n{auth_url}\n\nOr paste the redirected URL / query string below."
),
items: vec![InlineListItem {
title: "Submit".to_string(),
subtitle: Some("Press Tab to type text, then Enter to submit.".to_string()),
badge: None,
indent: 0,
selection: Some(InlineListSelection::RequestUserInputAnswer {
question_id: OPENAI_MANUAL_PROMPT_ID.to_string(),
selected: vec![],
other: Some(String::new()),
}),
search_value: Some("submit callback redirect url query string".to_string()),
}],
completed: false,
answer: None,
allow_freeform: true,
freeform_label: Some("Redirect URL or query".to_string()),
freeform_placeholder: Some(openai_manual_placeholder(callback_port)),
freeform_default: None,
};
let outcome = show_wizard_modal_and_wait(
ctx.handle,
ctx.session,
"OpenAI manual callback".to_string(),
vec![step],
0,
None,
WizardModalMode::MultiStep,
ctx.ctrl_c_state,
ctx.ctrl_c_notify,
)
.await?;
let value = match outcome {
WizardModalOutcome::Submitted(selections) => {
selections
.into_iter()
.find_map(|selection| match selection {
InlineListSelection::RequestUserInputAnswer {
question_id,
selected,
other,
} if question_id == OPENAI_MANUAL_PROMPT_ID => {
other.or_else(|| selected.first().cloned())
}
_ => None,
})
}
WizardModalOutcome::Cancelled { signal } => {
if matches!(signal, Some("cancel")) {
ctx.ctrl_c_state.mark_cancel_handled();
}
return Err(oauth_flow_cancelled_error());
}
};
Ok(value.and_then(|value| {
let trimmed = value.trim().to_string();
(!trimmed.is_empty()).then_some(trimmed)
}))
}
async fn show_oauth_provider_modal(
ctx: &mut SlashCommandContext<'_>,
action: OAuthProviderAction,
) -> Result<()> {
let vt_cfg = ctx.vt_cfg.as_ref();
let openrouter_status = openrouter_auth_status(vt_cfg)?;
let openai_status = openai_auth_status(vt_cfg)?;
let openai_overview = summarize_current_openai_credentials(vt_cfg)?;
let copilot_auth_cfg = vt_cfg
.map(|cfg| cfg.auth.copilot.clone())
.unwrap_or_default();
let copilot_status = probe_auth_status(&copilot_auth_cfg, Some(&ctx.config.workspace)).await;
let mut items = vec![
InlineListItem {
title: "GitHub Copilot".to_string(),
subtitle: Some(copilot_modal_subtitle(action, &copilot_status)),
badge: Some(copilot_modal_badge(action, &copilot_status)),
indent: 0,
selection: Some(InlineListSelection::ConfigAction(format!(
"{}{}",
OAUTH_PROVIDER_PREFIX, COPILOT_PROVIDER
))),
search_value: Some("github copilot cli auth".to_string()),
},
InlineListItem {
title: "OpenAI ChatGPT".to_string(),
subtitle: Some(openai_modal_subtitle(action, &openai_status)),
badge: Some(openai_modal_badge(action, &openai_status, &openai_overview)),
indent: 0,
selection: Some(InlineListSelection::ConfigAction(format!(
"{}{}",
OAUTH_PROVIDER_PREFIX, OPENAI_PROVIDER
))),
search_value: Some("openai chatgpt oauth subscription".to_string()),
},
InlineListItem {
title: "OpenRouter".to_string(),
subtitle: Some(openrouter_modal_subtitle(action, &openrouter_status)),
badge: Some(openrouter_modal_badge(action, &openrouter_status)),
indent: 0,
selection: Some(InlineListSelection::ConfigAction(format!(
"{}{}",
OAUTH_PROVIDER_PREFIX, OPENROUTER_PROVIDER
))),
search_value: Some("openrouter oauth".to_string()),
},
InlineListItem {
title: "Back".to_string(),
subtitle: Some("Close this dialog".to_string()),
badge: None,
indent: 0,
selection: Some(InlineListSelection::ConfigAction(
OAUTH_PROVIDER_BACK.to_string(),
)),
search_value: Some("back close cancel".to_string()),
},
];
if matches!(action, OAuthProviderAction::Refresh) {
items[0].badge = Some("Info".to_string());
items[1].badge = Some("Refresh".to_string());
items[2].badge = Some("Info".to_string());
}
ctx.renderer.show_list_modal(
oauth_modal_title(action),
oauth_modal_lines(action),
items,
Some(InlineListSelection::ConfigAction(format!(
"{}{}",
OAUTH_PROVIDER_PREFIX, COPILOT_PROVIDER
))),
None,
);
Ok(())
}
fn oauth_modal_title(action: OAuthProviderAction) -> &'static str {
match action {
OAuthProviderAction::Login => "Authentication login",
OAuthProviderAction::Logout => "Authentication logout",
OAuthProviderAction::Refresh => "Refresh authentication",
}
}
fn oauth_modal_lines(action: OAuthProviderAction) -> Vec<String> {
match action {
OAuthProviderAction::Login => vec![
"Choose a provider to connect.".to_string(),
"VT Code stores OpenAI/OpenRouter credentials securely and uses the official `copilot` CLI for GitHub Copilot.".to_string(),
],
OAuthProviderAction::Logout => vec![
"Choose a provider to disconnect.".to_string(),
"This removes the stored authentication session for the selected provider.".to_string(),
],
OAuthProviderAction::Refresh => vec![
"Choose a provider to refresh.".to_string(),
"OpenAI refreshes the stored ChatGPT session; OpenRouter and GitHub Copilot require a new login.".to_string(),
],
}
}
#[cfg(test)]
mod tests {
use super::*;
use hashbrown::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use vtcode_core::config::loader::VTCodeConfig;
use vtcode_core::llm::provider as uni;
use vtcode_tui::app::InlineHeaderContext;
use crate::agent::runloop::model_picker::ModelPickerState;
use crate::agent::runloop::unified::palettes::ActivePalette;
use crate::agent::runloop::unified::session_setup::IdeContextBridge;
use crate::agent::runloop::unified::state::CtrlCSignal;
use crate::agent::runloop::unified::turn::turn_processing::test_support::TestTurnProcessingBacking;
use crate::agent::runloop::unified::workspace_links::LinkedDirectory;
use crate::agent::runloop::welcome::SessionBootstrap;
use vtcode_core::skills::types::Skill;
#[tokio::test]
async fn openrouter_login_returns_continue_when_ctrl_c_is_pending() {
let mut backing = TestTurnProcessingBacking::new(4).await;
let turn = backing.turn_loop_context();
assert!(matches!(
turn.ctrl_c_state.register_signal(),
CtrlCSignal::Cancel
));
let header_context = Box::leak(Box::new(InlineHeaderContext::default()));
let ide_context_bridge = Box::leak(Box::new(None::<IdeContextBridge>));
let model_picker_state = Box::leak(Box::new(None::<ModelPickerState>));
let palette_state = Box::leak(Box::new(None::<ActivePalette>));
let conversation_history = Box::leak(Box::new(Vec::<uni::Message>::new()));
let loaded_skills = Box::leak(Box::new(Arc::new(RwLock::new(
HashMap::<String, Skill>::new(),
))));
let linked_directories = Box::leak(Box::new(Vec::<LinkedDirectory>::new()));
let session_bootstrap = Box::leak(Box::new(SessionBootstrap::default()));
let vt_cfg = Box::leak(Box::new(None::<VTCodeConfig>));
let async_mcp_manager = None;
let checkpoint_manager = None;
let lifecycle_hooks = None;
let harness_emitter = None;
let thread_handle = Box::leak(Box::new(
vtcode_core::core::threads::ThreadManager::new().start_thread_with_identifier(
"test-thread",
vtcode_core::core::threads::ThreadBootstrap::new(None),
),
));
let ctx = SlashCommandContext {
thread_id: "test-thread",
active_thread_label: "main",
thread_handle,
renderer: turn.renderer,
handle: turn.handle,
session: turn.session,
header_context,
ide_context_bridge,
config: turn.config,
vt_cfg,
provider_client: turn.provider_client,
session_bootstrap,
model_picker_state,
palette_state,
tool_registry: turn.tool_registry,
conversation_history,
decision_ledger: turn.decision_ledger,
context_manager: turn.context_manager,
session_stats: turn.session_stats,
input_status_state: turn.input_status_state,
tools: turn.tools,
tool_catalog: turn.tool_catalog,
async_mcp_manager,
mcp_panel_state: turn.mcp_panel_state,
linked_directories,
ctrl_c_state: turn.ctrl_c_state,
ctrl_c_notify: turn.ctrl_c_notify,
full_auto: turn.full_auto,
loaded_skills,
checkpoint_manager,
lifecycle_hooks,
harness_emitter,
};
let control = handle_oauth_login(ctx, OPENROUTER_PROVIDER.to_string())
.await
.expect("openrouter login should continue after cancel");
assert!(matches!(control, SlashCommandControl::Continue));
}
}
fn copilot_modal_subtitle(action: OAuthProviderAction, status: &CopilotAuthStatus) -> String {
match action {
OAuthProviderAction::Login => match status.kind {
CopilotAuthStatusKind::Authenticated => {
"Managed auth is connected; rerun the Copilot CLI login to replace the active session."
.to_string()
}
CopilotAuthStatusKind::Unauthenticated => {
"Sign in with your GitHub Copilot subscription through the official Copilot CLI."
.to_string()
}
CopilotAuthStatusKind::ServerUnavailable => {
format!(
"Copilot CLI unavailable; install `copilot` or configure `[auth.copilot].command`. See {COPILOT_AUTH_DOC_PATH}."
)
}
CopilotAuthStatusKind::AuthFlowFailed => {
"Authentication needs attention; rerun the Copilot CLI login flow.".to_string()
}
},
OAuthProviderAction::Logout => match status.kind {
CopilotAuthStatusKind::Authenticated => {
"Remove the active GitHub Copilot CLI session.".to_string()
}
_ => "No stored GitHub Copilot session to remove.".to_string(),
},
OAuthProviderAction::Refresh => {
"GitHub Copilot requires a new login instead of token refresh.".to_string()
}
}
}
fn copilot_modal_badge(action: OAuthProviderAction, status: &CopilotAuthStatus) -> String {
if matches!(action, OAuthProviderAction::Refresh) {
return "Info".to_string();
}
match status.kind {
CopilotAuthStatusKind::Authenticated => "Connected".to_string(),
CopilotAuthStatusKind::ServerUnavailable => "Unavailable".to_string(),
CopilotAuthStatusKind::AuthFlowFailed => "Attention".to_string(),
CopilotAuthStatusKind::Unauthenticated => "Auth".to_string(),
}
}
fn openai_modal_subtitle(action: OAuthProviderAction, status: &OpenAIChatGptAuthStatus) -> String {
match action {
OAuthProviderAction::Login => match status {
OpenAIChatGptAuthStatus::Authenticated { label, .. } => format!(
"Connected{}; re-authenticate to replace the stored ChatGPT session.",
label
.as_deref()
.map(|value| format!(" as {}", value))
.unwrap_or_default()
),
OpenAIChatGptAuthStatus::NotAuthenticated => {
"Sign in with your ChatGPT subscription.".to_string()
}
},
OAuthProviderAction::Logout => match status {
OpenAIChatGptAuthStatus::Authenticated { label, .. } => format!(
"Remove the stored ChatGPT session{}.",
label
.as_deref()
.map(|value| format!(" for {}", value))
.unwrap_or_default()
),
OpenAIChatGptAuthStatus::NotAuthenticated => {
"No stored ChatGPT session to remove.".to_string()
}
},
OAuthProviderAction::Refresh => match status {
OpenAIChatGptAuthStatus::Authenticated { .. } => {
"Refresh the stored ChatGPT session using its refresh token.".to_string()
}
OpenAIChatGptAuthStatus::NotAuthenticated => {
"No stored ChatGPT session to refresh yet.".to_string()
}
},
}
}
fn openai_modal_badge(
action: OAuthProviderAction,
status: &OpenAIChatGptAuthStatus,
overview: &vtcode_config::auth::OpenAICredentialOverview,
) -> String {
if matches!(action, OAuthProviderAction::Refresh) {
return if matches!(status, OpenAIChatGptAuthStatus::Authenticated { .. }) {
"Refresh".to_string()
} else {
"Missing".to_string()
};
}
if overview.active_source == Some(OpenAIResolvedAuthSource::ChatGpt) {
return "Active".to_string();
}
match status {
OpenAIChatGptAuthStatus::Authenticated { .. } => "Connected".to_string(),
OpenAIChatGptAuthStatus::NotAuthenticated => "OAuth".to_string(),
}
}
fn openrouter_modal_subtitle(action: OAuthProviderAction, status: &AuthStatus) -> String {
match action {
OAuthProviderAction::Login => match status {
AuthStatus::Authenticated { label, .. } => format!(
"Connected{}; re-authenticate to replace the stored OpenRouter token.",
label
.as_deref()
.map(|value| format!(" as {}", value))
.unwrap_or_default()
),
AuthStatus::NotAuthenticated => "Sign in with OpenRouter OAuth.".to_string(),
},
OAuthProviderAction::Logout => match status {
AuthStatus::Authenticated { .. } => {
"Remove the stored OpenRouter OAuth token.".to_string()
}
AuthStatus::NotAuthenticated => {
"No stored OpenRouter OAuth token to remove.".to_string()
}
},
OAuthProviderAction::Refresh => {
"OpenRouter does not expose a refresh-token flow; reconnect with /login openrouter."
.to_string()
}
}
}
fn openrouter_modal_badge(action: OAuthProviderAction, status: &AuthStatus) -> String {
if matches!(action, OAuthProviderAction::Refresh) {
return "Info".to_string();
}
match status {
AuthStatus::Authenticated { .. } => "Connected".to_string(),
AuthStatus::NotAuthenticated => "OAuth".to_string(),
}
}
fn render_openrouter_auth_status(renderer: &mut AnsiRenderer, status: AuthStatus) -> Result<()> {
match status {
AuthStatus::Authenticated {
label,
age_seconds,
expires_in,
} => {
renderer.line(MessageStyle::Info, "OpenRouter: authenticated (OAuth)")?;
if let Some(label) = label {
renderer.line(MessageStyle::Output, &format!(" Label: {}", label))?;
}
renderer.line(
MessageStyle::Output,
&format!(" Token obtained: {}", format_auth_duration(age_seconds)),
)?;
if let Some(expires_in) = expires_in {
renderer.line(
MessageStyle::Output,
&format!(" Expires in: {}", format_auth_duration(expires_in)),
)?;
}
}
AuthStatus::NotAuthenticated => {
if get_api_key(OPENROUTER_PROVIDER, &ApiKeySources::default()).is_ok() {
renderer.line(MessageStyle::Info, "OpenRouter: using OPENROUTER_API_KEY")?;
} else {
renderer.line(MessageStyle::Info, "OpenRouter: not authenticated")?;
}
}
}
Ok(())
}
fn render_openai_auth_status(
renderer: &mut AnsiRenderer,
status: OpenAIChatGptAuthStatus,
) -> Result<()> {
match status {
OpenAIChatGptAuthStatus::Authenticated {
label,
age_seconds,
expires_in,
} => {
renderer.line(MessageStyle::Info, "OpenAI: authenticated (ChatGPT)")?;
if let Some(label) = label {
renderer.line(MessageStyle::Output, &format!(" Label: {}", label))?;
}
renderer.line(
MessageStyle::Output,
&format!(" Session obtained: {}", format_auth_duration(age_seconds)),
)?;
if let Some(expires_in) = expires_in {
renderer.line(
MessageStyle::Output,
&format!(" Expires in: {}", format_auth_duration(expires_in)),
)?;
}
}
OpenAIChatGptAuthStatus::NotAuthenticated => {
if get_api_key(OPENAI_PROVIDER, &ApiKeySources::default()).is_ok() {
renderer.line(MessageStyle::Info, "OpenAI: using OPENAI_API_KEY")?;
} else {
renderer.line(MessageStyle::Info, "OpenAI: not authenticated")?;
}
}
}
Ok(())
}
fn render_copilot_auth_status(
renderer: &mut AnsiRenderer,
status: CopilotAuthStatus,
) -> Result<()> {
match status.kind {
CopilotAuthStatusKind::Authenticated => {
renderer.line(
MessageStyle::Info,
"GitHub Copilot: authenticated (managed auth via Copilot CLI)",
)?;
}
CopilotAuthStatusKind::Unauthenticated => {
renderer.line(MessageStyle::Info, "GitHub Copilot: not authenticated")?;
}
CopilotAuthStatusKind::ServerUnavailable => {
renderer.line(MessageStyle::Warning, "GitHub Copilot: CLI unavailable")?;
}
CopilotAuthStatusKind::AuthFlowFailed => {
renderer.line(MessageStyle::Warning, "GitHub Copilot: auth flow failed")?;
}
}
if let Some(message) = status.message.as_deref()
&& !message.trim().is_empty()
{
renderer.line(MessageStyle::Output, &format!(" Details: {}", message))?;
}
if matches!(status.kind, CopilotAuthStatusKind::ServerUnavailable) {
renderer.line(
MessageStyle::Output,
&format!(
" Help: install `copilot` or configure `[auth.copilot].command`; see {COPILOT_AUTH_DOC_PATH}."
),
)?;
}
Ok(())
}
fn render_openai_credential_overview(
renderer: &mut AnsiRenderer,
vt_cfg: Option<&vtcode_config::VTCodeConfig>,
current_provider_is_openai: bool,
) -> Result<()> {
let overview = summarize_current_openai_credentials(vt_cfg)?;
renderer.line(
MessageStyle::Output,
&format!(
" API key: {}",
if overview.api_key_available {
"available"
} else {
"not found"
}
),
)?;
renderer.line(
MessageStyle::Output,
&format!(
" ChatGPT session: {}",
if overview.chatgpt_session.is_some() {
"connected"
} else {
"not connected"
}
),
)?;
let usage_status = match overview.active_source {
Some(OpenAIResolvedAuthSource::ChatGpt) => "using ChatGPT subscription",
Some(OpenAIResolvedAuthSource::ApiKey) => "using OPENAI_API_KEY",
None => "no active OpenAI credential",
};
renderer.line(
MessageStyle::Output,
&format!(
" Usage status: {} (preferred_method = {})",
usage_status,
overview.preferred_method.as_str()
),
)?;
if current_provider_is_openai {
renderer.line(
MessageStyle::Output,
&format!(" Current session: {}", usage_status),
)?;
}
if let Some(notice) = overview.notice.as_deref() {
renderer.line(MessageStyle::Info, &format!(" Notice: {}", notice))?;
}
if let Some(recommendation) = overview.recommendation.as_deref() {
renderer.line(
MessageStyle::Output,
&format!(" Recommendation: {}", recommendation),
)?;
}
Ok(())
}
fn summarize_current_openai_credentials(
vt_cfg: Option<&vtcode_config::VTCodeConfig>,
) -> Result<vtcode_config::auth::OpenAICredentialOverview> {
let default_auth = vtcode_auth::OpenAIAuthConfig::default();
let auth_cfg = vt_cfg.map(|cfg| &cfg.auth.openai).unwrap_or(&default_auth);
let storage_mode = vt_cfg
.map(|cfg| cfg.agent.credential_storage_mode)
.unwrap_or_default();
let api_key = get_api_key(OPENAI_PROVIDER, &ApiKeySources::default()).ok();
vtcode_config::auth::summarize_openai_credentials(auth_cfg, storage_mode, api_key)
}
async fn sync_openai_runtime_if_active(ctx: &mut SlashCommandContext<'_>) -> Result<()> {
if !ctx.config.provider.eq_ignore_ascii_case(OPENAI_PROVIDER) {
return Ok(());
}
let api_key = get_api_key(OPENAI_PROVIDER, &ApiKeySources::default()).ok();
let (runtime_api_key, runtime_auth) = match ctx.vt_cfg.as_ref() {
Some(cfg) => match vtcode_config::auth::resolve_openai_auth(
&cfg.auth.openai,
cfg.agent.credential_storage_mode,
api_key,
) {
Ok(resolved) => (resolved.api_key().to_string(), resolved.handle()),
Err(_) => (String::new(), None),
},
None => (api_key.unwrap_or_default(), None),
};
let provider = create_provider_with_config(
OPENAI_PROVIDER,
ProviderConfig {
api_key: Some(runtime_api_key.clone()),
openai_chatgpt_auth: runtime_auth.clone(),
copilot_auth: ctx.vt_cfg.as_ref().map(|cfg| cfg.auth.copilot.clone()),
base_url: None,
model: Some(ctx.config.model.clone()),
prompt_cache: Some(ctx.config.prompt_cache.clone()),
timeouts: None,
openai: ctx.vt_cfg.as_ref().map(|cfg| cfg.provider.openai.clone()),
anthropic: None,
model_behavior: ctx.config.model_behavior.clone(),
workspace_root: Some(ctx.config.workspace.clone()),
},
)?;
*ctx.provider_client = provider;
ctx.config.api_key = runtime_api_key;
ctx.config.openai_chatgpt_auth = runtime_auth;
let provider_label = if ctx.config.openai_chatgpt_auth.is_some() {
"OpenAI (ChatGPT)".to_string()
} else {
"openai".to_string()
};
let mode_label = match (ctx.config.ui_surface, ctx.full_auto) {
(UiSurfacePreference::Inline, true) => "auto".to_string(),
(UiSurfacePreference::Inline, false) => "inline".to_string(),
(UiSurfacePreference::Alternate, _) => "alt".to_string(),
(UiSurfacePreference::Auto, true) => "auto".to_string(),
(UiSurfacePreference::Auto, false) => "std".to_string(),
};
let next_header_context = build_inline_header_context(
ctx.config,
ctx.vt_cfg.as_ref(),
ctx.session_bootstrap,
provider_label,
ctx.config.model.clone(),
ctx.provider_client
.effective_context_size(&ctx.config.model),
mode_label,
ctx.config.reasoning_effort.as_str().to_string(),
)
.await?;
ctx.header_context.clone_from(&next_header_context);
ctx.handle.set_header_context(next_header_context);
Ok(())
}
async fn sync_copilot_runtime_if_active(ctx: &mut SlashCommandContext<'_>) -> Result<()> {
if !ctx.config.provider.eq_ignore_ascii_case(COPILOT_PROVIDER) {
return Ok(());
}
let provider = create_provider_with_config(
COPILOT_PROVIDER,
ProviderConfig {
api_key: Some(String::new()),
openai_chatgpt_auth: None,
copilot_auth: ctx.vt_cfg.as_ref().map(|cfg| cfg.auth.copilot.clone()),
base_url: None,
model: Some(ctx.config.model.clone()),
prompt_cache: Some(ctx.config.prompt_cache.clone()),
timeouts: None,
openai: ctx.vt_cfg.as_ref().map(|cfg| cfg.provider.openai.clone()),
anthropic: None,
model_behavior: ctx.config.model_behavior.clone(),
workspace_root: Some(ctx.config.workspace.clone()),
},
)?;
*ctx.provider_client = provider;
ctx.config.api_key.clear();
ctx.config.openai_chatgpt_auth = None;
let mode_label = match (ctx.config.ui_surface, ctx.full_auto) {
(UiSurfacePreference::Inline, true) => "auto".to_string(),
(UiSurfacePreference::Inline, false) => "inline".to_string(),
(UiSurfacePreference::Alternate, _) => "alt".to_string(),
(UiSurfacePreference::Auto, true) => "auto".to_string(),
(UiSurfacePreference::Auto, false) => "std".to_string(),
};
let next_header_context = build_inline_header_context(
ctx.config,
ctx.vt_cfg.as_ref(),
ctx.session_bootstrap,
"GitHub Copilot".to_string(),
ctx.config.model.clone(),
ctx.provider_client
.effective_context_size(&ctx.config.model),
mode_label,
ctx.config.reasoning_effort.as_str().to_string(),
)
.await?;
ctx.header_context.clone_from(&next_header_context);
ctx.handle.set_header_context(next_header_context);
Ok(())
}
fn format_auth_duration(seconds: u64) -> String {
if seconds < 60 {
format!("{seconds}s")
} else if seconds < 3600 {
format!("{}m {}s", seconds / 60, seconds % 60)
} else if seconds < 86_400 {
format!("{}h {}m", seconds / 3600, (seconds % 3600) / 60)
} else {
format!("{}d {}h", seconds / 86_400, (seconds % 86_400) / 3600)
}
}
#[derive(Clone, Copy)]
enum CopilotAuthAction {
Login,
Logout,
}
fn render_copilot_auth_intro(renderer: &mut AnsiRenderer, action: CopilotAuthAction) -> Result<()> {
renderer.line(MessageStyle::Info, "Managed auth via GitHub Copilot CLI.")?;
renderer.line(
MessageStyle::Info,
"`gh` is optional fallback only; login/logout require the official `copilot` CLI.",
)?;
renderer.line(
MessageStyle::Info,
match action {
CopilotAuthAction::Login => {
"Waiting for the official Copilot CLI to start the managed login flow."
}
CopilotAuthAction::Logout => "Clearing the managed GitHub Copilot CLI session.",
},
)?;
Ok(())
}
fn render_copilot_auth_event(renderer: &mut AnsiRenderer, event: CopilotAuthEvent) -> Result<()> {
match event {
CopilotAuthEvent::VerificationCode { url, user_code } => {
renderer.line(
MessageStyle::Info,
"Your GitHub device code — copy it before the browser opens:",
)?;
let device_code_style = AnsiStyle::new()
.fg_color(Some(Color::Ansi(AnsiColor::BrightYellow)))
.effects(Effects::BOLD);
renderer.line_with_style(device_code_style, &user_code)?;
notify_attention(true, Some(&format!("GitHub device code: {user_code}")));
renderer.line(MessageStyle::Info, "Opening browser to:")?;
renderer.hyperlink_line(MessageStyle::Info, &url)?;
renderer.line(MessageStyle::Info, "Enter the code above when prompted.")?;
}
CopilotAuthEvent::Progress { message } => renderer.line(MessageStyle::Info, &message)?,
CopilotAuthEvent::Success { account } => {
if let Some(account) = account.as_deref() {
renderer.line(MessageStyle::Output, &format!("Account: {account}"))?;
}
}
CopilotAuthEvent::Failure { message } => {
renderer.line(MessageStyle::Info, &format!("Failure: {message}"))?;
}
}
Ok(())
}