Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 81 additions & 6 deletions src/banner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,53 @@
use std::path::Path;

use crate::consts::{AUTHOR, HOMEPAGE, REPO, format_number};
use crate::provider::ProviderStatus;
use crate::thinker::TokenUsage;

/// Auth status for a single provider in the banner.
pub struct BannerProvider<'a> {
pub display_name: &'a str,
pub auth_status: &'a str,
pub is_active: bool,
}

/// Session configuration for display in the startup banner.
pub struct BannerInfo<'a> {
pub provider: &'a str,
pub model: &'a str,
pub auth_status: &'a str,
pub providers: &'a [ProviderStatus],
pub shell_mode: &'a str,
pub working_dir: &'a Path,
pub memory: &'a str,
}

/// Print the startup banner with session info.
pub fn print_banner(info: &BannerInfo) {
// Build auth lines aligned with other banner fields.
// Field label column is 10 chars ("provider ", "shell ", etc.).
let mut auth_lines = String::new();
for status in info.providers {
let marker = if status.id == info.provider {
" ← active"
} else {
""
};
let label = if auth_lines.is_empty() {
"auth "
} else {
" "
};
auth_lines.push_str(&format!(
" {label}{} ({}){}\n",
status.display_name, status.auth_status, marker,
));
}

// Fallback if no providers
if auth_lines.is_empty() {
auth_lines = " auth N/A\n".to_string();
}

println!(
r#"
╔═══════════════════════════════════════╗
Expand All @@ -29,8 +62,7 @@ pub fn print_banner(info: &BannerInfo) {
home {}
repo {}
provider {} ({})
auth {}
shell {}
{} shell {}
workdir {}
memory {}
"#,
Expand All @@ -40,7 +72,7 @@ pub fn print_banner(info: &BannerInfo) {
REPO,
info.provider,
info.model,
info.auth_status,
auth_lines,
info.shell_mode,
info.working_dir.display(),
info.memory,
Expand All @@ -67,15 +99,58 @@ mod tests {

#[test]
fn print_banner_does_not_panic() {
let statuses = vec![ProviderStatus {
id: "anthropic",
display_name: "Anthropic (Claude Pro/Max)",
auth_status: "OAuth ✓".to_string(),
}];
let info = BannerInfo {
provider: "anthropic",
model: "claude-sonnet-4-20250514",
providers: &statuses,
shell_mode: "read-only",
working_dir: &PathBuf::from("/tmp/test"),
memory: "ephemeral",
};
// Just verify it doesn't panic
print_banner(&info);
}

#[test]
fn print_banner_multiple_providers() {
let statuses = vec![
ProviderStatus {
id: "anthropic",
display_name: "Anthropic (Claude Pro/Max)",
auth_status: "OAuth ✓".to_string(),
},
ProviderStatus {
id: "google",
display_name: "Google (Gemini)",
auth_status: "not authenticated".to_string(),
},
];
let info = BannerInfo {
provider: "anthropic",
model: "claude-sonnet-4-20250514",
providers: &statuses,
shell_mode: "read-only",
working_dir: &PathBuf::from("/tmp/test"),
memory: "ephemeral",
};
print_banner(&info);
}

#[test]
fn print_banner_no_providers() {
let info = BannerInfo {
provider: "human",
model: "—",
auth_status: "N/A",
providers: &[],
shell_mode: "read-only",
working_dir: &PathBuf::from("/tmp/test"),
memory: "ephemeral",
};
// Just verify it doesn't panic
print_banner(&info);
}

Expand Down
13 changes: 9 additions & 4 deletions src/commands/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use tokio::io::AsyncBufReadExt;

use super::{Command, CommandResult, SessionInfo, StateChange};
use crate::commands::parse_menu_choice;
use crate::provider::all_login_providers;
use crate::provider::{all_login_providers, provider_auth_status};

pub struct LoginCommand;

Expand Down Expand Up @@ -63,11 +63,16 @@ impl Command for LoginCommand {
match config.login(info.db_path).await {
Ok(()) => {
println!(" ✓ logged in to {}", config.display_name());
// Only update REPL auth status if logging into the current provider
if config.id() == info.provider {
CommandResult::StateChanged(StateChange::Auth("OAuth ✓".to_string()))
// Same provider — derive actual auth status from storage
let status = provider_auth_status(info.db_path, config.id());
CommandResult::StateChanged(StateChange::Auth(status))
} else {
CommandResult::Handled
// Different provider — switch to it (use its default model)
CommandResult::StateChanged(StateChange::Provider(
config.id().to_string(),
None,
))
}
}
Err(e) => {
Expand Down
89 changes: 89 additions & 0 deletions src/commands/logout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ use async_trait::async_trait;

use super::{Command, CommandResult, SessionInfo, StateChange};
use crate::auth;
use crate::auth::storage::AuthStorage;
use crate::provider::all_login_providers;

pub struct LogoutCommand;

Expand All @@ -22,10 +24,35 @@ impl Command for LogoutCommand {
return CommandResult::Handled;
}
println!(" ✓ logged out from {provider}");

// Try to fall back to another authenticated provider
if let Some(fallback_id) = find_authenticated_provider(info.db_path, provider) {
println!(" → switching to {fallback_id}");
return CommandResult::StateChanged(StateChange::Provider(fallback_id, None));
}

CommandResult::StateChanged(StateChange::Auth("not authenticated".to_string()))
}
}

/// Find another provider that is authenticated (stored credentials or env var), skipping `exclude`.
fn find_authenticated_provider(db_path: &str, exclude: &str) -> Option<String> {
let storage = AuthStorage::open(db_path).ok()?;
for config in all_login_providers() {
if config.id() == exclude {
continue;
}
let has_stored = storage.get(config.id()).ok().flatten().is_some();
let has_env = std::env::var(config.env_var())
.map(|k| !k.is_empty())
.unwrap_or(false);
if has_stored || has_env {
return Some(config.id().to_string());
}
}
None
}

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -34,6 +61,8 @@ mod tests {

#[tokio::test]
async fn returns_auth_changed_when_no_credentials() {
// With :memory: db, no other provider is authenticated,
// so logout returns Auth (not Provider fallback).
assert!(matches!(
LogoutCommand.execute(&test_info()).await,
CommandResult::StateChanged(StateChange::Auth(_))
Expand Down Expand Up @@ -63,4 +92,64 @@ mod tests {
// Note: the command opens its own connection to :memory:,
// so this tests the command flow, not the same DB instance.
}

#[test]
fn find_authenticated_provider_returns_none_when_empty() {
assert!(find_authenticated_provider(":memory:", "anthropic").is_none());
}

#[test]
fn find_authenticated_provider_skips_excluded() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("fallback.db");
let db_str = db_path.to_str().unwrap();

let storage = AuthStorage::open(db_str).unwrap();
storage
.set(
"anthropic",
Credential::ApiKey {
key: "key".to_string(),
},
)
.unwrap();
drop(storage);

// Excluding anthropic should return None (only anthropic has creds)
assert!(find_authenticated_provider(db_str, "anthropic").is_none());
}

#[test]
fn find_authenticated_provider_finds_other() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("fallback2.db");
let db_str = db_path.to_str().unwrap();

let storage = AuthStorage::open(db_str).unwrap();
storage
.set(
"anthropic",
Credential::ApiKey {
key: "key-a".to_string(),
},
)
.unwrap();
storage
.set(
"google",
Credential::ApiKey {
key: "key-g".to_string(),
},
)
.unwrap();
drop(storage);

// Excluding anthropic should find google
let fallback = find_authenticated_provider(db_str, "anthropic");
assert_eq!(fallback.as_deref(), Some("google"));

// Excluding google should find anthropic
let fallback = find_authenticated_provider(db_str, "google");
assert_eq!(fallback.as_deref(), Some("anthropic"));
}
}
33 changes: 33 additions & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ pub enum StateChange {
Auth(String),
/// Active model changed (new model ID).
Model(String),
/// Active provider changed (provider ID, optional model override).
/// The REPL should rebuild the thinker for this provider.
/// Pass `None` as the model to use the provider's default.
Provider(String, Option<String>),
}

/// What the REPL should do after a command runs.
Expand Down Expand Up @@ -328,6 +332,35 @@ mod tests {
}
}

#[tokio::test]
async fn state_changed_provider_variant_carries_value() {
struct FakeProviderCommand;

#[async_trait]
impl Command for FakeProviderCommand {
fn name(&self) -> &str {
"/fakeprovider"
}
fn description(&self) -> &str {
"test"
}
async fn execute(&self, _info: &SessionInfo<'_>) -> CommandResult {
CommandResult::StateChanged(StateChange::Provider("google".to_string(), None))
}
}

let mut reg = CommandRegistry::new();
reg.register(Arc::new(FakeProviderCommand));

match reg.dispatch("/fakeprovider", &test_info()).await {
CommandResult::StateChanged(StateChange::Provider(id, model)) => {
assert_eq!(id, "google");
assert!(model.is_none());
}
other => panic!("expected StateChanged(Provider), got: {other:?}"),
}
}

#[test]
fn format_label_no_aliases() {
assert_eq!(format_label("/whoami", &[]), "/whoami");
Expand Down
Loading