Skip to main content

pi/interactive/
commands.rs

1use super::*;
2
3use crate::models::ModelEntry;
4
5#[cfg(feature = "clipboard")]
6use arboard::Clipboard as ArboardClipboard;
7
8/// Available slash commands.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum SlashCommand {
11    Help,
12    Login,
13    Logout,
14    Clear,
15    Model,
16    Thinking,
17    ScopedModels,
18    Exit,
19    History,
20    Export,
21    Session,
22    Settings,
23    Theme,
24    Resume,
25    New,
26    Copy,
27    Name,
28    Hotkeys,
29    Changelog,
30    Tree,
31    Fork,
32    Compact,
33    Reload,
34    Share,
35}
36
37impl SlashCommand {
38    /// Parse a slash command from input.
39    pub fn parse(input: &str) -> Option<(Self, &str)> {
40        let input = input.trim();
41        if !input.starts_with('/') {
42            return None;
43        }
44
45        let (cmd, args) = input.split_once(char::is_whitespace).unwrap_or((input, ""));
46
47        let command = match cmd.to_lowercase().as_str() {
48            "/help" | "/h" | "/?" => Self::Help,
49            "/login" => Self::Login,
50            "/logout" => Self::Logout,
51            "/clear" | "/cls" => Self::Clear,
52            "/model" | "/m" => Self::Model,
53            "/thinking" | "/think" | "/t" => Self::Thinking,
54            "/scoped-models" | "/scoped" => Self::ScopedModels,
55            "/exit" | "/quit" | "/q" => Self::Exit,
56            "/history" | "/hist" => Self::History,
57            "/export" => Self::Export,
58            "/session" | "/info" => Self::Session,
59            "/settings" => Self::Settings,
60            "/theme" => Self::Theme,
61            "/resume" | "/r" => Self::Resume,
62            "/new" => Self::New,
63            "/copy" | "/cp" => Self::Copy,
64            "/name" => Self::Name,
65            "/hotkeys" | "/keys" | "/keybindings" => Self::Hotkeys,
66            "/changelog" => Self::Changelog,
67            "/tree" => Self::Tree,
68            "/fork" => Self::Fork,
69            "/compact" => Self::Compact,
70            "/reload" => Self::Reload,
71            "/share" => Self::Share,
72            _ => return None,
73        };
74
75        Some((command, args.trim()))
76    }
77
78    /// Get help text for all commands.
79    pub const fn help_text() -> &'static str {
80        r"Available commands:
81  /help, /h, /?      - Show this help message
82  /login [provider]  - Login/setup credentials; without provider shows status table
83  /logout [provider] - Remove stored credentials
84  /clear, /cls       - Clear conversation history
85  /model, /m [id|provider/id] - Open model selector or switch directly
86  /thinking, /t [level] - Set thinking level (off/minimal/low/medium/high/xhigh)
87  /scoped-models [patterns|clear] - Show or set scoped models for cycling
88  /history, /hist    - Show input history
89  /export [path]     - Export conversation to HTML
90  /session, /info    - Show session info (path, tokens, cost)
91  /settings          - Open settings selector
92  /theme [name]      - List or switch themes (dark/light/custom)
93  /resume, /r        - Pick and resume a previous session
94  /new               - Start a new session
95  /copy, /cp         - Copy last assistant message to clipboard
96  /name <name>       - Set session display name
97  /hotkeys, /keys    - Show keyboard shortcuts
98  /changelog         - Show changelog entries
99  /tree              - Show session branch tree summary
100  /fork [id|index]   - Fork from a user message (default: last on current path)
101  /compact [notes]   - Compact older context with optional instructions
102  /reload            - Reload skills/prompts from disk
103  /share             - Upload session HTML to a secret GitHub gist and show URL
104  /exit, /quit, /q   - Exit Pi
105
106  Tips:
107    • Use ↑/↓ arrows to navigate input history
108    • Use Ctrl+L to open model selector
109    • Use Ctrl+P to cycle scoped models
110    • Use Shift+Enter (Ctrl+Enter on Windows) to insert a newline
111    • Use PageUp/PageDown to scroll conversation history
112    • Use Escape to cancel current input
113    • Use /skill:name or /template to expand resources"
114    }
115}
116
117pub(super) fn parse_extension_command(input: &str) -> Option<(String, Vec<String>)> {
118    let input = input.trim();
119    if !input.starts_with('/') {
120        return None;
121    }
122
123    // Built-in slash commands are handled elsewhere.
124    if SlashCommand::parse(input).is_some() {
125        return None;
126    }
127
128    let (cmd, rest) = input.split_once(char::is_whitespace).unwrap_or((input, ""));
129    let cmd = cmd.trim_start_matches('/').trim();
130    if cmd.is_empty() {
131        return None;
132    }
133    let args = rest
134        .split_whitespace()
135        .map(std::string::ToString::to_string)
136        .collect();
137    Some((cmd.to_string(), args))
138}
139
140pub(super) fn parse_bash_command(input: &str) -> Option<(String, bool)> {
141    let trimmed = input.trim_start();
142    let (rest, force) = trimmed
143        .strip_prefix("!!")
144        .map(|r| (r, true))
145        .or_else(|| trimmed.strip_prefix('!').map(|r| (r, false)))?;
146    let command = rest.trim();
147    if command.is_empty() {
148        return None;
149    }
150    Some((command.to_string(), force))
151}
152
153pub(super) fn normalize_api_key_input(raw: &str) -> std::result::Result<String, String> {
154    let key = raw.trim();
155    if key.is_empty() {
156        return Err("API key cannot be empty".to_string());
157    }
158    if key.chars().any(char::is_whitespace) {
159        return Err("API key must not contain whitespace".to_string());
160    }
161    Ok(key.to_string())
162}
163
164pub(super) fn normalize_auth_provider_input(raw: &str) -> String {
165    let provider = raw.trim().to_ascii_lowercase();
166    crate::provider_metadata::canonical_provider_id(&provider)
167        .unwrap_or(provider.as_str())
168        .to_string()
169}
170
171pub(super) fn api_key_login_prompt(provider: &str) -> Option<&'static str> {
172    match provider {
173        "openai" => Some(
174            "API key login: openai\n\n\
175Paste your OpenAI API key to save it in auth.json.\n\
176Get a key from platform.openai.com/api-keys.\n\
177Rotate/revoke keys from that dashboard if compromised.\n\n\
178Your input will be treated as sensitive and is not added to message history.",
179        ),
180        "google" => Some(
181            "API key login: google/gemini\n\n\
182Paste your Google Gemini API key to save it in auth.json under google.\n\
183Get a key from ai.google.dev/gemini-api/docs/api-key.\n\
184Rotate/revoke keys from Google AI Studio if compromised.\n\n\
185Your input will be treated as sensitive and is not added to message history.",
186        ),
187        _ => None,
188    }
189}
190
191pub(super) fn save_provider_credential(
192    auth: &mut crate::auth::AuthStorage,
193    provider: &str,
194    credential: crate::auth::AuthCredential,
195) {
196    let requested = provider.trim().to_ascii_lowercase();
197    let canonical = normalize_auth_provider_input(&requested);
198    let _ = auth.remove_provider_aliases(&requested);
199    if requested != canonical {
200        let _ = auth.remove_provider_aliases(&canonical);
201    }
202    auth.set(canonical.clone(), credential);
203}
204
205pub(super) fn remove_provider_credentials(
206    auth: &mut crate::auth::AuthStorage,
207    requested_provider: &str,
208) -> bool {
209    let requested = requested_provider.trim().to_ascii_lowercase();
210    let canonical = normalize_auth_provider_input(&requested);
211
212    let mut removed = auth.remove_provider_aliases(&canonical);
213    if requested != canonical {
214        removed |= auth.remove_provider_aliases(&requested);
215    }
216    removed
217}
218
219const BUILTIN_LOGIN_PROVIDERS: [(&str, &str); 9] = [
220    ("anthropic", "OAuth"),
221    ("openai-codex", "OAuth"),
222    ("google-gemini-cli", "OAuth"),
223    ("google-antigravity", "OAuth"),
224    ("kimi-for-coding", "OAuth"),
225    ("github-copilot", "OAuth"),
226    ("gitlab", "OAuth"),
227    ("openai", "API key"),
228    ("google", "API key"),
229];
230
231const STARTUP_PRIORITY_OAUTH_PROVIDERS: [(&str, &str); 3] = [
232    ("anthropic", "Claude Code"),
233    ("openai-codex", "Codex"),
234    ("google-gemini-cli", "Gemini CLI"),
235];
236
237fn format_compact_duration(ms: i64) -> String {
238    let seconds = (ms.max(0) / 1000).max(1);
239    if seconds < 60 {
240        format!("{seconds}s")
241    } else if seconds < 60 * 60 {
242        format!("{}m", seconds / 60)
243    } else if seconds < 24 * 60 * 60 {
244        format!("{}h", seconds / (60 * 60))
245    } else {
246        format!("{}d", seconds / (24 * 60 * 60))
247    }
248}
249
250fn format_credential_status(status: &crate::auth::CredentialStatus) -> String {
251    match status {
252        crate::auth::CredentialStatus::Missing => "Not authenticated".to_string(),
253        crate::auth::CredentialStatus::ApiKey
254        | crate::auth::CredentialStatus::BearerToken
255        | crate::auth::CredentialStatus::AwsCredentials
256        | crate::auth::CredentialStatus::ServiceKey => "Authenticated".to_string(),
257        crate::auth::CredentialStatus::OAuthValid { expires_in_ms } => {
258            format!(
259                "Authenticated (expires in {})",
260                format_compact_duration(*expires_in_ms)
261            )
262        }
263        crate::auth::CredentialStatus::OAuthExpired { expired_by_ms } => {
264            format!(
265                "Authenticated (expired {} ago)",
266                format_compact_duration(*expired_by_ms)
267            )
268        }
269    }
270}
271
272fn format_provider_status(auth: &crate::auth::AuthStorage, provider: &str) -> String {
273    if let Some(source) = auth.external_setup_source(provider)
274        && !auth.has_stored_credential(provider)
275    {
276        return format!("Auto-detected from {source}");
277    }
278
279    let status = auth.credential_status(provider);
280    format_credential_status(&status)
281}
282
283fn collect_extension_oauth_providers(available_models: &[ModelEntry]) -> Vec<String> {
284    let mut providers: Vec<String> = available_models
285        .iter()
286        .filter(|entry| entry.oauth_config.is_some())
287        .map(|entry| {
288            let provider = entry.model.provider.as_str();
289            crate::provider_metadata::canonical_provider_id(provider)
290                .unwrap_or(provider)
291                .to_string()
292        })
293        .collect();
294
295    providers.retain(|provider| {
296        !BUILTIN_LOGIN_PROVIDERS
297            .iter()
298            .any(|(builtin, _)| provider == builtin)
299    });
300    providers.sort_unstable();
301    providers.dedup();
302    providers
303}
304
305fn extension_oauth_config_for_provider(
306    available_models: &[ModelEntry],
307    provider: &str,
308) -> Option<crate::models::OAuthConfig> {
309    available_models.iter().find_map(|entry| {
310        let model_provider = entry.model.provider.as_str();
311        let canonical = crate::provider_metadata::canonical_provider_id(model_provider)
312            .unwrap_or(model_provider);
313        if canonical.eq_ignore_ascii_case(provider) {
314            entry.oauth_config.clone()
315        } else {
316            None
317        }
318    })
319}
320
321fn append_provider_rows(output: &mut String, heading: &str, rows: &[(String, String, String)]) {
322    let provider_width = rows
323        .iter()
324        .map(|(provider, _, _)| provider.len())
325        .max()
326        .unwrap_or("provider".len())
327        .max("provider".len());
328    let method_width = rows
329        .iter()
330        .map(|(_, method, _)| method.len())
331        .max()
332        .unwrap_or("method".len())
333        .max("method".len());
334
335    let _ = writeln!(output, "{heading}:");
336    let _ = writeln!(
337        output,
338        "  {:<provider_width$}  {:<method_width$}  status",
339        "provider", "method"
340    );
341    for (provider, method, status) in rows {
342        let _ = writeln!(
343            output,
344            "  {provider:<provider_width$}  {method:<method_width$}  {status}"
345        );
346    }
347}
348
349pub(super) fn format_login_provider_listing(
350    auth: &crate::auth::AuthStorage,
351    available_models: &[ModelEntry],
352) -> String {
353    let mut output = String::from("Available login providers:\n\n");
354
355    let built_in_rows: Vec<(String, String, String)> = BUILTIN_LOGIN_PROVIDERS
356        .iter()
357        .map(|(provider, method)| {
358            (
359                (*provider).to_string(),
360                (*method).to_string(),
361                format_provider_status(auth, provider),
362            )
363        })
364        .collect();
365    append_provider_rows(&mut output, "Built-in", &built_in_rows);
366
367    let extension_providers = collect_extension_oauth_providers(available_models);
368    if !extension_providers.is_empty() {
369        let extension_rows: Vec<(String, String, String)> = extension_providers
370            .iter()
371            .map(|provider| {
372                (
373                    provider.clone(),
374                    "OAuth".to_string(),
375                    format_provider_status(auth, provider),
376                )
377            })
378            .collect();
379        output.push('\n');
380        append_provider_rows(&mut output, "Extension providers", &extension_rows);
381    }
382
383    output.push_str("\nUsage: /login <provider>");
384    output
385}
386
387pub(super) fn format_startup_oauth_hint(auth: &crate::auth::AuthStorage) -> String {
388    let mut output = String::new();
389    output.push_str("  No provider credentials were detected.\n");
390    output.push_str("  Connect one of these providers:\n");
391    for (provider, label) in STARTUP_PRIORITY_OAUTH_PROVIDERS {
392        let status = format_provider_status(auth, provider);
393        let _ = writeln!(output, "  - {provider} ({label}): {status}");
394    }
395    output.push_str("  Use /login <provider> to connect or refresh credentials.\n");
396    output.push_str("  Use /login to see all providers and auth methods.");
397    output
398}
399
400pub(super) fn should_show_startup_oauth_hint(auth: &crate::auth::AuthStorage) -> bool {
401    let has_any_credential = crate::provider_metadata::PROVIDER_METADATA
402        .iter()
403        .map(|meta| meta.canonical_id)
404        .any(|provider| {
405            auth.has_stored_credential(provider)
406                || auth.external_setup_source(provider).is_some()
407                || auth.resolve_api_key(provider, None).is_some()
408        });
409    if has_any_credential {
410        return false;
411    }
412
413    STARTUP_PRIORITY_OAUTH_PROVIDERS
414        .iter()
415        .all(|(provider, _)| {
416            auth.resolve_api_key(provider, None).is_none()
417                && !auth.has_stored_credential(provider)
418                && auth.external_setup_source(provider).is_none()
419        })
420}
421
422pub fn strip_thinking_level_suffix(pattern: &str) -> &str {
423    let Some((prefix, suffix)) = pattern.rsplit_once(':') else {
424        return pattern;
425    };
426    match suffix.to_ascii_lowercase().as_str() {
427        "off" | "minimal" | "low" | "medium" | "high" | "xhigh" => prefix,
428        _ => pattern,
429    }
430}
431
432pub fn parse_scoped_model_patterns(args: &str) -> Vec<String> {
433    args.split(|c: char| c == ',' || c.is_whitespace())
434        .map(str::trim)
435        .filter(|s| !s.is_empty())
436        .map(ToString::to_string)
437        .collect()
438}
439
440pub fn model_entry_matches(left: &ModelEntry, right: &ModelEntry) -> bool {
441    let left_provider = crate::provider_metadata::canonical_provider_id(&left.model.provider)
442        .unwrap_or(&left.model.provider);
443    let right_provider = crate::provider_metadata::canonical_provider_id(&right.model.provider)
444        .unwrap_or(&right.model.provider);
445
446    left_provider.eq_ignore_ascii_case(right_provider)
447        && left.model.id.eq_ignore_ascii_case(&right.model.id)
448}
449
450pub(super) fn normalize_api_key_opt(api_key: Option<String>) -> Option<String> {
451    api_key.and_then(|key| {
452        let trimmed = key.trim();
453        (!trimmed.is_empty()).then(|| trimmed.to_string())
454    })
455}
456
457pub(super) fn model_requires_configured_credential(entry: &ModelEntry) -> bool {
458    let provider = entry.model.provider.as_str();
459    entry.auth_header
460        || crate::provider_metadata::provider_metadata(provider)
461            .is_some_and(|meta| !meta.auth_env_keys.is_empty())
462        || entry.oauth_config.is_some()
463}
464
465pub(super) fn resolve_model_key_with_auth(
466    auth: &crate::auth::AuthStorage,
467    entry: &ModelEntry,
468) -> Option<String> {
469    normalize_api_key_opt(auth.resolve_api_key(&entry.model.provider, None))
470        .or_else(|| normalize_api_key_opt(entry.api_key.clone()))
471}
472
473pub(super) fn resolve_model_key_from_default_auth(entry: &ModelEntry) -> Option<String> {
474    let auth_path = crate::config::Config::auth_path();
475    crate::auth::AuthStorage::load(auth_path)
476        .ok()
477        .and_then(|auth| resolve_model_key_with_auth(&auth, entry))
478        .or_else(|| normalize_api_key_opt(entry.api_key.clone()))
479}
480
481fn provider_ids_match(left: &str, right: &str) -> bool {
482    normalize_auth_provider_input(left) == normalize_auth_provider_input(right)
483}
484
485fn split_provider_model_spec(model_spec: &str) -> Option<(&str, &str)> {
486    let (provider, model_id) = model_spec.split_once('/')?;
487    let provider = provider.trim();
488    let model_id = model_id.trim();
489    if provider.is_empty() || model_id.is_empty() {
490        return None;
491    }
492    Some((provider, model_id))
493}
494
495pub fn resolve_scoped_model_entries(
496    patterns: &[String],
497    available_models: &[ModelEntry],
498) -> Result<Vec<ModelEntry>, String> {
499    let mut resolved: Vec<ModelEntry> = Vec::new();
500
501    for pattern in patterns {
502        let raw_pattern = strip_thinking_level_suffix(pattern);
503        let is_glob =
504            raw_pattern.contains('*') || raw_pattern.contains('?') || raw_pattern.contains('[');
505
506        if is_glob {
507            let glob = Pattern::new(&raw_pattern.to_lowercase())
508                .map_err(|err| format!("Invalid model pattern \"{pattern}\": {err}"))?;
509
510            for entry in available_models {
511                let full_id = format!("{}/{}", entry.model.provider, entry.model.id);
512                let full_id_lower = full_id.to_lowercase();
513                let id_lower = entry.model.id.to_lowercase();
514
515                if (glob.matches(&full_id_lower) || glob.matches(&id_lower))
516                    && !resolved
517                        .iter()
518                        .any(|existing| model_entry_matches(existing, entry))
519                {
520                    resolved.push(entry.clone());
521                }
522            }
523            continue;
524        }
525
526        for entry in available_models {
527            let full_id = format!("{}/{}", entry.model.provider, entry.model.id);
528            if raw_pattern.eq_ignore_ascii_case(&full_id)
529                || raw_pattern.eq_ignore_ascii_case(&entry.model.id)
530            {
531                if !resolved
532                    .iter()
533                    .any(|existing| model_entry_matches(existing, entry))
534                {
535                    resolved.push(entry.clone());
536                }
537                break;
538            }
539        }
540    }
541
542    resolved.sort_by(|a, b| {
543        let left = format!("{}/{}", a.model.provider, a.model.id);
544        let right = format!("{}/{}", b.model.provider, b.model.id);
545        left.cmp(&right)
546    });
547
548    Ok(resolved)
549}
550
551pub(super) const fn kind_rank(kind: &DiagnosticKind) -> u8 {
552    match kind {
553        DiagnosticKind::Warning => 0,
554        DiagnosticKind::Collision => 1,
555    }
556}
557
558pub(super) fn format_resource_diagnostics(
559    label: &str,
560    diagnostics: &[ResourceDiagnostic],
561) -> (String, usize) {
562    let mut ordered: Vec<&ResourceDiagnostic> = diagnostics.iter().collect();
563    ordered.sort_by(|a, b| {
564        a.path
565            .cmp(&b.path)
566            .then_with(|| kind_rank(&a.kind).cmp(&kind_rank(&b.kind)))
567            .then_with(|| a.message.cmp(&b.message))
568    });
569
570    let mut out = String::new();
571    let _ = writeln!(out, "{label}:");
572    for diag in ordered {
573        let kind = match diag.kind {
574            DiagnosticKind::Warning => "warning",
575            DiagnosticKind::Collision => "collision",
576        };
577        let _ = write!(out, "- {kind}: {} ({})", diag.message, diag.path.display());
578        if let Some(collision) = &diag.collision {
579            let _ = write!(
580                out,
581                " [winner: {} loser: {}]",
582                collision.winner_path.display(),
583                collision.loser_path.display()
584            );
585        }
586        out.push('\n');
587    }
588    (out, diagnostics.len())
589}
590
591fn build_reload_diagnostics(
592    models_error: Option<String>,
593    resources: &ResourceLoader,
594) -> (Option<String>, usize) {
595    let mut sections = Vec::new();
596    let mut count = 0usize;
597
598    if let Some(err) = models_error {
599        count = count.saturating_add(1);
600        sections.push(format!("models.json:\n{err}"));
601    }
602
603    let mut resource_sections = Vec::new();
604    let (skills_text, skills_count) =
605        format_resource_diagnostics("Skills", resources.skill_diagnostics());
606    if skills_count > 0 {
607        resource_sections.push(skills_text);
608        count = count.saturating_add(skills_count);
609    }
610
611    let (prompts_text, prompts_count) =
612        format_resource_diagnostics("Prompts", resources.prompt_diagnostics());
613    if prompts_count > 0 {
614        resource_sections.push(prompts_text);
615        count = count.saturating_add(prompts_count);
616    }
617
618    let (themes_text, themes_count) =
619        format_resource_diagnostics("Themes", resources.theme_diagnostics());
620    if themes_count > 0 {
621        resource_sections.push(themes_text);
622        count = count.saturating_add(themes_count);
623    }
624
625    if !resource_sections.is_empty() {
626        sections.push(format!(
627            "Resource diagnostics:\n{}",
628            resource_sections.join("\n")
629        ));
630    }
631
632    if sections.is_empty() {
633        (None, 0)
634    } else {
635        (
636            Some(format!("Reload diagnostics:\n\n{}", sections.join("\n\n"))),
637            count,
638        )
639    }
640}
641
642impl PiApp {
643    pub(super) fn sync_active_provider_credentials(&mut self, changed_provider: &str) {
644        let changed_canonical = normalize_auth_provider_input(changed_provider);
645        let auth = match crate::auth::AuthStorage::load(crate::config::Config::auth_path()) {
646            Ok(auth) => auth,
647            Err(err) => {
648                tracing::warn!(
649                    event = "pi.auth.sync_credentials.load_failed",
650                    provider = %changed_canonical,
651                    error = %err,
652                    "Skipping in-memory credential sync because auth storage could not be loaded"
653                );
654                return;
655            }
656        };
657
658        let provider_matches_changed =
659            |provider: &str| normalize_auth_provider_input(provider) == changed_canonical;
660
661        if !provider_matches_changed(&self.model_entry.model.provider) {
662            return;
663        }
664
665        // Keep catalog/model-scope entries immutable here so inline model keys
666        // are never overwritten by transient auth state. We only refresh the
667        // active runtime key.
668        let fallback_inline_key = self
669            .available_models
670            .iter()
671            .find(|entry| model_entry_matches(entry, &self.model_entry))
672            .and_then(|entry| normalize_api_key_opt(entry.api_key.clone()))
673            .or_else(|| normalize_api_key_opt(self.model_entry.api_key.clone()));
674
675        let resolved_key_opt =
676            normalize_api_key_opt(auth.resolve_api_key(&changed_canonical, None))
677                .or(fallback_inline_key);
678
679        if let Ok(mut agent_guard) = self.agent.try_lock() {
680            agent_guard
681                .stream_options_mut()
682                .api_key
683                .clone_from(&resolved_key_opt);
684        }
685
686        self.model_entry.api_key.clone_from(&resolved_key_opt);
687        if let Ok(mut shared_entry) = self.model_entry_shared.lock() {
688            shared_entry.api_key.clone_from(&resolved_key_opt);
689        }
690    }
691
692    #[allow(clippy::too_many_lines)]
693    pub(super) fn submit_oauth_code(
694        &mut self,
695        code_input: &str,
696        pending: PendingOAuth,
697    ) -> Option<Cmd> {
698        // Do not store OAuth codes in history or session.
699        self.input.reset();
700        self.input_mode = InputMode::SingleLine;
701        self.set_input_height(3);
702
703        self.agent_state = AgentState::Processing;
704        self.scroll_to_bottom();
705
706        let event_tx = self.event_tx.clone();
707        let PendingOAuth {
708            provider,
709            kind,
710            verifier,
711            oauth_config,
712            device_code,
713        } = pending;
714        let code_input = code_input.to_string();
715
716        let runtime_handle = self.runtime_handle.clone();
717        runtime_handle.spawn(async move {
718            let auth_path = crate::config::Config::auth_path();
719            let mut auth = match crate::auth::AuthStorage::load_async(auth_path).await {
720                Ok(a) => a,
721                Err(e) => {
722                    let _ = event_tx.try_send(PiMsg::AgentError(e.to_string()));
723                    return;
724                }
725            };
726
727            let credential = match kind {
728                PendingLoginKind::ApiKey => normalize_api_key_input(&code_input)
729                    .map(|key| crate::auth::AuthCredential::ApiKey { key })
730                    .map_err(crate::error::Error::auth),
731                PendingLoginKind::OAuth => {
732                    if provider == "anthropic" {
733                        Box::pin(crate::auth::complete_anthropic_oauth(
734                            &code_input,
735                            &verifier,
736                        ))
737                        .await
738                    } else if provider == "openai-codex" {
739                        Box::pin(crate::auth::complete_openai_codex_oauth(
740                            &code_input,
741                            &verifier,
742                        ))
743                        .await
744                    } else if provider == "google-gemini-cli" {
745                        Box::pin(crate::auth::complete_google_gemini_cli_oauth(
746                            &code_input,
747                            &verifier,
748                        ))
749                        .await
750                    } else if provider == "google-antigravity" {
751                        Box::pin(crate::auth::complete_google_antigravity_oauth(
752                            &code_input,
753                            &verifier,
754                        ))
755                        .await
756                    } else if provider == "github-copilot" || provider == "copilot" {
757                        let client_id =
758                            std::env::var("GITHUB_COPILOT_CLIENT_ID").unwrap_or_default();
759                        let copilot_config = crate::auth::CopilotOAuthConfig {
760                            client_id,
761                            ..crate::auth::CopilotOAuthConfig::default()
762                        };
763                        Box::pin(crate::auth::complete_copilot_browser_oauth(
764                            &copilot_config,
765                            &code_input,
766                            &verifier,
767                        ))
768                        .await
769                    } else if provider == "gitlab" || provider == "gitlab-duo" {
770                        let client_id = std::env::var("GITLAB_CLIENT_ID").unwrap_or_default();
771                        let base_url = std::env::var("GITLAB_BASE_URL")
772                            .unwrap_or_else(|_| "https://gitlab.com".to_string());
773                        let gitlab_config = crate::auth::GitLabOAuthConfig {
774                            client_id,
775                            base_url,
776                            ..crate::auth::GitLabOAuthConfig::default()
777                        };
778                        Box::pin(crate::auth::complete_gitlab_oauth(
779                            &gitlab_config,
780                            &code_input,
781                            &verifier,
782                        ))
783                        .await
784                    } else if let Some(config) = &oauth_config {
785                        Box::pin(crate::auth::complete_extension_oauth(
786                            config,
787                            &code_input,
788                            &verifier,
789                        ))
790                        .await
791                    } else {
792                        Err(crate::error::Error::auth(format!(
793                            "OAuth provider not supported: {provider}"
794                        )))
795                    }
796                }
797                PendingLoginKind::DeviceFlow => match device_code {
798                    Some(dc) => {
799                        let poll_result = if provider == "kimi-for-coding" {
800                            Box::pin(crate::auth::poll_kimi_code_device_flow(&dc)).await
801                        } else {
802                            let client_id =
803                                std::env::var("GITHUB_COPILOT_CLIENT_ID").unwrap_or_default();
804                            let copilot_config = crate::auth::CopilotOAuthConfig {
805                                client_id,
806                                ..crate::auth::CopilotOAuthConfig::default()
807                            };
808                            Box::pin(crate::auth::poll_copilot_device_flow(&copilot_config, &dc))
809                                .await
810                        };
811                        match poll_result {
812                            crate::auth::DeviceFlowPollResult::Success(cred) => Ok(cred),
813                            crate::auth::DeviceFlowPollResult::Error(e) => {
814                                Err(crate::error::Error::auth(e))
815                            }
816                            crate::auth::DeviceFlowPollResult::Expired => {
817                                Err(crate::error::Error::auth(format!(
818                                    "Device code expired for {provider}. Run /login {provider} again."
819                                )))
820                            }
821                            crate::auth::DeviceFlowPollResult::AccessDenied => {
822                                Err(crate::error::Error::auth(format!(
823                                    "Access denied for {provider}."
824                                )))
825                            }
826                            crate::auth::DeviceFlowPollResult::Pending => {
827                                Err(crate::error::Error::auth(format!(
828                                    "Authorization for {provider} is still pending. Complete the browser step and submit again."
829                                )))
830                            }
831                            crate::auth::DeviceFlowPollResult::SlowDown => {
832                                Err(crate::error::Error::auth(format!(
833                                    "Authorization server asked to slow down for {provider}. Wait a few seconds and submit again."
834                                )))
835                            }
836                        }
837                    }
838                    None => Err(crate::error::Error::auth(
839                        "Device flow missing device_code".to_string(),
840                    )),
841                },
842            };
843
844            let credential = match credential {
845                Ok(c) => c,
846                Err(e) => {
847                    let _ = event_tx.try_send(PiMsg::AgentError(e.to_string()));
848                    return;
849                }
850            };
851
852            save_provider_credential(&mut auth, &provider, credential);
853            if let Err(e) = auth.save_async().await {
854                let _ = event_tx.try_send(PiMsg::AgentError(e.to_string()));
855                return;
856            }
857            let _ = event_tx.try_send(PiMsg::CredentialUpdated {
858                provider: provider.clone(),
859            });
860
861            let status = match kind {
862                PendingLoginKind::ApiKey => {
863                    format!("API key saved for {provider}. Credentials saved to auth.json.")
864                }
865                PendingLoginKind::OAuth | PendingLoginKind::DeviceFlow => {
866                    format!(
867                        "OAuth login successful for {provider}. Credentials saved to auth.json."
868                    )
869                }
870            };
871            let _ = event_tx.try_send(PiMsg::System(status));
872        });
873
874        None
875    }
876
877    pub(super) fn submit_bash_command(
878        &mut self,
879        raw_message: &str,
880        command: String,
881        exclude_from_context: bool,
882    ) -> Option<Cmd> {
883        if self.bash_running {
884            self.status_message = Some("A bash command is already running.".to_string());
885            return None;
886        }
887
888        self.bash_running = true;
889        self.agent_state = AgentState::ToolRunning;
890        self.current_tool = Some("bash".to_string());
891        self.history.push(raw_message.to_string());
892
893        self.input.reset();
894        self.input_mode = InputMode::SingleLine;
895        self.set_input_height(3);
896
897        let event_tx = self.event_tx.clone();
898        let session = Arc::clone(&self.session);
899        let save_enabled = self.save_enabled;
900        let cwd = self.cwd.clone();
901        let shell_path = self.config.shell_path.clone();
902        let command_prefix = self.config.shell_command_prefix.clone();
903        let runtime_handle = self.runtime_handle.clone();
904
905        runtime_handle.spawn(async move {
906            let cx = Cx::for_request();
907            let result = crate::tools::run_bash_command(
908                &cwd,
909                shell_path.as_deref(),
910                command_prefix.as_deref(),
911                &command,
912                None,
913                None,
914            )
915            .await;
916
917            match result {
918                Ok(result) => {
919                    let display =
920                        bash_execution_to_text(&command, &result.output, 0, false, false, None);
921
922                    if exclude_from_context {
923                        let mut extra = HashMap::new();
924                        extra.insert("excludeFromContext".to_string(), Value::Bool(true));
925
926                        let bash_message = SessionMessage::BashExecution {
927                            command: command.clone(),
928                            output: result.output.clone(),
929                            exit_code: result.exit_code,
930                            cancelled: Some(result.cancelled),
931                            truncated: Some(result.truncated),
932                            full_output_path: result.full_output_path.clone(),
933                            timestamp: Some(Utc::now().timestamp_millis()),
934                            extra,
935                        };
936
937                        if let Ok(mut session_guard) = session.lock(&cx).await {
938                            session_guard.append_message(bash_message);
939                            if save_enabled {
940                                let _ = session_guard.save().await;
941                            }
942                        }
943
944                        let mut display = display;
945                        display.push_str("\n\n[Output excluded from model context]");
946                        let _ = event_tx.try_send(PiMsg::BashResult {
947                            display,
948                            content_for_agent: None,
949                        });
950                    } else {
951                        let content_for_agent =
952                            vec![ContentBlock::Text(TextContent::new(display.clone()))];
953                        let _ = event_tx.try_send(PiMsg::BashResult {
954                            display,
955                            content_for_agent: Some(content_for_agent),
956                        });
957                    }
958                }
959                Err(err) => {
960                    let _ = event_tx.try_send(PiMsg::BashResult {
961                        display: format!("Bash command failed: {err}"),
962                        content_for_agent: None,
963                    });
964                }
965            }
966        });
967
968        None
969    }
970
971    pub(super) fn format_themes_list(&self) -> String {
972        let mut names = Vec::new();
973        names.push("dark".to_string());
974        names.push("light".to_string());
975        names.push("solarized".to_string());
976
977        for path in Theme::discover_themes(&self.cwd) {
978            if let Ok(theme) = Theme::load(&path) {
979                names.push(theme.name);
980            }
981        }
982
983        names.sort_by_key(|a| a.to_ascii_lowercase());
984        names.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
985
986        let mut output = String::from("Available themes:\n");
987        for name in names {
988            let marker = if name.eq_ignore_ascii_case(&self.theme.name) {
989                "* "
990            } else {
991                "  "
992            };
993            let _ = writeln!(output, "{marker}{name}");
994        }
995        output.push_str("\nUse /theme <name> to switch");
996        output
997    }
998
999    pub(super) fn format_scoped_models_status(&self) -> String {
1000        let patterns = self.config.enabled_models.as_deref().unwrap_or(&[]);
1001        let scope_configured = !patterns.is_empty();
1002
1003        let mut output = String::new();
1004        let current = format!(
1005            "{}/{}",
1006            self.model_entry.model.provider, self.model_entry.model.id
1007        );
1008        let _ = writeln!(output, "Current model: {current}");
1009        let _ = writeln!(output);
1010
1011        if !scope_configured {
1012            let _ = writeln!(output, "Scoped models: (all models)");
1013            let _ = writeln!(output);
1014            output.push_str("Use /scoped-models <patterns> to scope Ctrl+P cycling.\n");
1015            output.push_str("Use /scoped-models clear to clear scope.\n");
1016            return output;
1017        }
1018
1019        output.push_str("Scoped model patterns:\n");
1020        for pattern in patterns {
1021            let _ = writeln!(output, "  - {pattern}");
1022        }
1023        let _ = writeln!(output);
1024
1025        output.push_str("Scoped models (matched):\n");
1026        if self.model_scope.is_empty() {
1027            output.push_str("  (none)\n");
1028        } else {
1029            let mut models = self
1030                .model_scope
1031                .iter()
1032                .map(|entry| format!("{}/{}", entry.model.provider, entry.model.id))
1033                .collect::<Vec<_>>();
1034            models.sort_by_key(|value| value.to_ascii_lowercase());
1035            models.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
1036            for model in models {
1037                let _ = writeln!(output, "  - {model}");
1038            }
1039        }
1040        let _ = writeln!(output);
1041
1042        output.push_str("Use /scoped-models clear to cycle all models.\n");
1043        output
1044    }
1045
1046    pub(super) fn format_input_history(&self) -> String {
1047        let entries = self.history.entries();
1048        if entries.is_empty() {
1049            return "No input history yet.".to_string();
1050        }
1051
1052        let mut output = String::from("Input history (most recent first):\n");
1053        for (idx, entry) in entries.iter().rev().take(50).enumerate() {
1054            let trimmed = entry.value.trim();
1055            if trimmed.is_empty() {
1056                continue;
1057            }
1058            let preview = trimmed.replace('\n', "\\n");
1059            let preview = preview.chars().take(120).collect::<String>();
1060            let _ = writeln!(output, "  {}. {preview}", idx + 1);
1061        }
1062        output
1063    }
1064
1065    pub(super) fn format_session_info(&self, session: &Session) -> String {
1066        let file = session.path.as_ref().map_or_else(
1067            || "(not saved yet)".to_string(),
1068            |p| p.display().to_string(),
1069        );
1070        let name = session.get_name().unwrap_or_else(|| "-".to_string());
1071        let thinking = session
1072            .header
1073            .thinking_level
1074            .as_deref()
1075            .unwrap_or("off")
1076            .to_string();
1077
1078        let message_count = session
1079            .entries_for_current_path()
1080            .iter()
1081            .filter(|entry| matches!(entry, SessionEntry::Message(_)))
1082            .count();
1083
1084        let total_tokens = self.total_usage.total_tokens;
1085        let total_cost = self.total_usage.cost.total;
1086        let cost_str = if total_cost > 0.0 {
1087            format!("${total_cost:.4}")
1088        } else {
1089            "$0.0000".to_string()
1090        };
1091
1092        let mut info = format!(
1093            "Session info:\n  file: {file}\n  id: {id}\n  name: {name}\n  model: {model}\n  thinking: {thinking}\n  messageCount: {message_count}\n  tokens: {total_tokens}\n  cost: {cost_str}",
1094            id = session.header.id,
1095            model = self.model,
1096        );
1097        info.push_str("\n\n");
1098        info.push_str(&self.frame_timing.summary());
1099        info.push_str("\n\n");
1100        info.push_str(&self.memory_monitor.summary());
1101        info
1102    }
1103
1104    /// Handle a slash command.
1105    #[allow(clippy::too_many_lines)]
1106    pub(super) fn handle_slash_command(&mut self, cmd: SlashCommand, args: &str) -> Option<Cmd> {
1107        // Clear input
1108        self.input.reset();
1109
1110        match cmd {
1111            SlashCommand::Help => {
1112                self.messages.push(ConversationMessage {
1113                    role: MessageRole::System,
1114                    content: SlashCommand::help_text().to_string(),
1115                    thinking: None,
1116                    collapsed: false,
1117                });
1118                self.scroll_to_last_match("Available commands:");
1119                None
1120            }
1121            SlashCommand::Login => self.handle_slash_login(args),
1122            SlashCommand::Logout => self.handle_slash_logout(args),
1123            SlashCommand::Clear => {
1124                self.messages.clear();
1125                self.current_response.clear();
1126                self.current_thinking.clear();
1127                self.current_tool = None;
1128                self.pending_tool_output = None;
1129                self.abort_handle = None;
1130                self.autocomplete.close();
1131                self.message_render_cache.clear();
1132                self.status_message = Some("Conversation cleared".to_string());
1133                self.scroll_to_bottom();
1134                None
1135            }
1136            SlashCommand::Model => self.handle_slash_model(args),
1137            SlashCommand::Thinking => self.handle_slash_thinking(args),
1138            SlashCommand::ScopedModels => self.handle_slash_scoped_models(args),
1139            SlashCommand::Exit => Some(self.quit_cmd()),
1140            SlashCommand::History => {
1141                self.messages.push(ConversationMessage {
1142                    role: MessageRole::System,
1143                    content: self.format_input_history(),
1144                    thinking: None,
1145                    collapsed: false,
1146                });
1147                self.scroll_to_last_match("Input history");
1148                None
1149            }
1150            SlashCommand::Export => {
1151                if self.agent_state != AgentState::Idle {
1152                    self.status_message = Some("Cannot export while processing".to_string());
1153                    return None;
1154                }
1155
1156                let (output_path, html) = {
1157                    let Ok(session_guard) = self.session.try_lock() else {
1158                        self.status_message = Some("Session busy; try again".to_string());
1159                        return None;
1160                    };
1161                    let output_path = if args.trim().is_empty() {
1162                        self.default_export_path(&session_guard)
1163                    } else {
1164                        self.resolve_output_path(args)
1165                    };
1166                    let html = session_guard.to_html();
1167                    (output_path, html)
1168                };
1169
1170                if let Some(parent) = output_path.parent() {
1171                    if !parent.as_os_str().is_empty() {
1172                        if let Err(err) = std::fs::create_dir_all(parent) {
1173                            self.status_message = Some(format!("Failed to create dir: {err}"));
1174                            return None;
1175                        }
1176                    }
1177                }
1178                if let Err(err) = std::fs::write(&output_path, html) {
1179                    self.status_message = Some(format!("Failed to write export: {err}"));
1180                    return None;
1181                }
1182
1183                self.messages.push(ConversationMessage {
1184                    role: MessageRole::System,
1185                    content: format!("Exported HTML: {}", output_path.display()),
1186                    thinking: None,
1187                    collapsed: false,
1188                });
1189                self.scroll_to_bottom();
1190                self.status_message = Some(format!("Exported: {}", output_path.display()));
1191                None
1192            }
1193            SlashCommand::Session => {
1194                let Ok(session_guard) = self.session.try_lock() else {
1195                    self.status_message = Some("Session busy; try again".to_string());
1196                    return None;
1197                };
1198                let info = self.format_session_info(&session_guard);
1199                drop(session_guard);
1200                self.messages.push(ConversationMessage {
1201                    role: MessageRole::System,
1202                    content: info,
1203                    thinking: None,
1204                    collapsed: false,
1205                });
1206                self.scroll_to_bottom();
1207                None
1208            }
1209            SlashCommand::Settings => {
1210                if self.agent_state != AgentState::Idle {
1211                    self.status_message = Some("Cannot open settings while processing".to_string());
1212                    return None;
1213                }
1214
1215                self.settings_ui = Some(SettingsUiState::new());
1216                self.session_picker = None;
1217                self.autocomplete.close();
1218                None
1219            }
1220            SlashCommand::Theme => {
1221                let name = args.trim();
1222                if name.is_empty() {
1223                    self.messages.push(ConversationMessage {
1224                        role: MessageRole::System,
1225                        content: self.format_themes_list(),
1226                        thinking: None,
1227                        collapsed: false,
1228                    });
1229                    self.scroll_to_last_match("Available themes:");
1230                    return None;
1231                }
1232
1233                let theme = if name.eq_ignore_ascii_case("dark") {
1234                    Theme::dark()
1235                } else if name.eq_ignore_ascii_case("light") {
1236                    Theme::light()
1237                } else if name.eq_ignore_ascii_case("solarized") {
1238                    Theme::solarized()
1239                } else {
1240                    match Theme::load_by_name(name, &self.cwd) {
1241                        Ok(theme) => theme,
1242                        Err(err) => {
1243                            self.status_message = Some(err.to_string());
1244                            return None;
1245                        }
1246                    }
1247                };
1248
1249                let theme_name = theme.name.clone();
1250                self.apply_theme(theme);
1251                self.config.theme = Some(theme_name.clone());
1252
1253                if let Err(err) = self.persist_project_theme(&theme_name) {
1254                    tracing::warn!("Failed to persist theme preference: {err}");
1255                    self.status_message = Some(format!(
1256                        "Switched to theme: {theme_name} (not saved: {err})"
1257                    ));
1258                } else {
1259                    self.status_message = Some(format!("Switched to theme: {theme_name}"));
1260                }
1261
1262                None
1263            }
1264            SlashCommand::Resume => {
1265                if self.agent_state != AgentState::Idle {
1266                    self.status_message = Some("Cannot resume while processing".to_string());
1267                    return None;
1268                }
1269
1270                let override_dir = self
1271                    .session
1272                    .try_lock()
1273                    .ok()
1274                    .and_then(|guard| guard.session_dir.clone());
1275                let base_dir = override_dir.clone().unwrap_or_else(Config::sessions_dir);
1276                let sessions = crate::session_picker::list_sessions_for_project(
1277                    &self.cwd,
1278                    override_dir.as_deref(),
1279                );
1280                if sessions.is_empty() {
1281                    self.status_message = Some("No sessions found for this project".to_string());
1282                    return None;
1283                }
1284
1285                self.session_picker = Some(SessionPickerOverlay::new_with_root(
1286                    sessions,
1287                    Some(base_dir),
1288                ));
1289                self.autocomplete.close();
1290                None
1291            }
1292            SlashCommand::New => {
1293                if self.agent_state != AgentState::Idle {
1294                    self.status_message =
1295                        Some("Cannot start a new session while processing".to_string());
1296                    return None;
1297                }
1298
1299                let Some(extensions) = self.extensions.clone() else {
1300                    let Ok(mut session_guard) = self.session.try_lock() else {
1301                        self.status_message = Some("Session busy; try again".to_string());
1302                        return None;
1303                    };
1304                    let session_dir = session_guard.session_dir.clone();
1305                    *session_guard = Session::create_with_dir(session_dir);
1306                    session_guard.header.provider = Some(self.model_entry.model.provider.clone());
1307                    session_guard.header.model_id = Some(self.model_entry.model.id.clone());
1308                    session_guard.header.thinking_level = Some(ThinkingLevel::Off.to_string());
1309                    drop(session_guard);
1310
1311                    if let Ok(mut agent_guard) = self.agent.try_lock() {
1312                        agent_guard.replace_messages(Vec::new());
1313                        agent_guard.stream_options_mut().thinking_level = Some(ThinkingLevel::Off);
1314                    }
1315
1316                    self.messages.clear();
1317                    self.message_render_cache.clear();
1318                    self.total_usage = Usage::default();
1319                    self.current_response.clear();
1320                    self.current_thinking.clear();
1321                    self.current_tool = None;
1322                    self.pending_tool_output = None;
1323                    self.abort_handle = None;
1324                    self.pending_oauth = None;
1325                    self.session_picker = None;
1326                    self.tree_ui = None;
1327                    self.autocomplete.close();
1328                    self.message_render_cache.clear();
1329
1330                    self.status_message = Some(format!(
1331                        "Started new session\nModel set to {}\nThinking level: off",
1332                        self.model
1333                    ));
1334                    self.scroll_to_bottom();
1335                    self.input.focus();
1336                    return None;
1337                };
1338
1339                let model_provider = self.model_entry.model.provider.clone();
1340                let model_id = self.model_entry.model.id.clone();
1341                let model_label = self.model.clone();
1342                let event_tx = self.event_tx.clone();
1343                let session = Arc::clone(&self.session);
1344                let agent = Arc::clone(&self.agent);
1345                let runtime_handle = self.runtime_handle.clone();
1346
1347                let previous_session_file = self
1348                    .session
1349                    .try_lock()
1350                    .ok()
1351                    .and_then(|guard| guard.path.as_ref().map(|p| p.display().to_string()));
1352
1353                self.agent_state = AgentState::Processing;
1354                self.status_message = Some("Starting new session...".to_string());
1355
1356                runtime_handle.spawn(async move {
1357                    let cx = Cx::for_request();
1358
1359                    let cancelled = extensions
1360                        .dispatch_cancellable_event(
1361                            ExtensionEventName::SessionBeforeSwitch,
1362                            Some(json!({ "reason": "new" })),
1363                            EXTENSION_EVENT_TIMEOUT_MS,
1364                        )
1365                        .await
1366                        .unwrap_or(false);
1367                    if cancelled {
1368                        let _ = event_tx.try_send(PiMsg::System(
1369                            "Session switch cancelled by extension".to_string(),
1370                        ));
1371                        return;
1372                    }
1373
1374                    let new_session_id = {
1375                        let mut guard = match session.lock(&cx).await {
1376                            Ok(guard) => guard,
1377                            Err(err) => {
1378                                let _ = event_tx.try_send(PiMsg::AgentError(format!(
1379                                    "Failed to lock session: {err}"
1380                                )));
1381                                return;
1382                            }
1383                        };
1384                        let session_dir = guard.session_dir.clone();
1385                        let mut new_session = Session::create_with_dir(session_dir);
1386                        new_session.header.provider = Some(model_provider);
1387                        new_session.header.model_id = Some(model_id);
1388                        new_session.header.thinking_level = Some(ThinkingLevel::Off.to_string());
1389                        let new_id = new_session.header.id.clone();
1390                        *guard = new_session;
1391                        new_id
1392                    };
1393
1394                    {
1395                        let mut agent_guard = match agent.lock(&cx).await {
1396                            Ok(guard) => guard,
1397                            Err(err) => {
1398                                let _ = event_tx.try_send(PiMsg::AgentError(format!(
1399                                    "Failed to lock agent: {err}"
1400                                )));
1401                                return;
1402                            }
1403                        };
1404                        agent_guard.replace_messages(Vec::new());
1405                        agent_guard.stream_options_mut().thinking_level = Some(ThinkingLevel::Off);
1406                    }
1407
1408                    let _ = event_tx.try_send(PiMsg::ConversationReset {
1409                        messages: Vec::new(),
1410                        usage: Usage::default(),
1411                        status: Some(format!(
1412                            "Started new session\nModel set to {model_label}\nThinking level: off"
1413                        )),
1414                    });
1415
1416                    let _ = extensions
1417                        .dispatch_event(
1418                            ExtensionEventName::SessionSwitch,
1419                            Some(json!({
1420                                "reason": "new",
1421                                "previousSessionFile": previous_session_file,
1422                                "sessionId": new_session_id,
1423                            })),
1424                        )
1425                        .await;
1426                });
1427
1428                None
1429            }
1430            SlashCommand::Copy => {
1431                if self.agent_state != AgentState::Idle {
1432                    self.status_message = Some("Cannot copy while processing".to_string());
1433                    return None;
1434                }
1435
1436                let text = self
1437                    .messages
1438                    .iter()
1439                    .rev()
1440                    .find(|m| m.role == MessageRole::Assistant && !m.content.trim().is_empty())
1441                    .map(|m| m.content.clone());
1442
1443                let Some(text) = text else {
1444                    self.status_message = Some("No agent messages to copy yet.".to_string());
1445                    return None;
1446                };
1447
1448                let write_fallback = |text: &str| -> std::io::Result<std::path::PathBuf> {
1449                    let dir = std::env::temp_dir();
1450                    let filename = format!("pi_copy_{}.txt", Utc::now().timestamp_millis());
1451                    let path = dir.join(filename);
1452                    std::fs::write(&path, text)?;
1453                    Ok(path)
1454                };
1455
1456                #[cfg(feature = "clipboard")]
1457                {
1458                    match ArboardClipboard::new()
1459                        .and_then(|mut clipboard| clipboard.set_text(text.clone()))
1460                    {
1461                        Ok(()) => self.status_message = Some("Copied to clipboard".to_string()),
1462                        Err(err) => match write_fallback(&text) {
1463                            Ok(path) => {
1464                                self.status_message = Some(format!(
1465                                    "Clipboard support is disabled or unavailable ({err}). Wrote to {}",
1466                                    path.display()
1467                                ));
1468                            }
1469                            Err(io_err) => {
1470                                self.status_message = Some(format!(
1471                                    "Clipboard support is disabled or unavailable ({err}); also failed to write fallback file: {io_err}"
1472                                ));
1473                            }
1474                        },
1475                    }
1476                }
1477
1478                #[cfg(not(feature = "clipboard"))]
1479                {
1480                    match write_fallback(&text) {
1481                        Ok(path) => {
1482                            self.status_message = Some(format!(
1483                                "Clipboard support is disabled. Wrote to {}",
1484                                path.display()
1485                            ));
1486                        }
1487                        Err(err) => {
1488                            self.status_message = Some(format!(
1489                                "Clipboard support is disabled; failed to write fallback file: {err}"
1490                            ));
1491                        }
1492                    }
1493                }
1494
1495                None
1496            }
1497            SlashCommand::Name => {
1498                let name = args.trim();
1499                if name.is_empty() {
1500                    self.status_message = Some("Usage: /name <name>".to_string());
1501                    return None;
1502                }
1503
1504                let Ok(mut session_guard) = self.session.try_lock() else {
1505                    self.status_message = Some("Session busy; try again".to_string());
1506                    return None;
1507                };
1508                session_guard.append_session_info(Some(name.to_string()));
1509                drop(session_guard);
1510                self.spawn_save_session();
1511
1512                self.status_message = Some(format!("Session name: {name}"));
1513                None
1514            }
1515            SlashCommand::Hotkeys => {
1516                self.messages.push(ConversationMessage {
1517                    role: MessageRole::System,
1518                    content: self.format_hotkeys(),
1519                    thinking: None,
1520                    collapsed: false,
1521                });
1522                self.scroll_to_bottom();
1523                None
1524            }
1525            SlashCommand::Changelog => {
1526                let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("CHANGELOG.md");
1527                match std::fs::read_to_string(&path) {
1528                    Ok(content) => {
1529                        self.messages.push(ConversationMessage {
1530                            role: MessageRole::System,
1531                            content,
1532                            thinking: None,
1533                            collapsed: false,
1534                        });
1535                        self.scroll_to_last_match("# ");
1536                    }
1537                    Err(err) => {
1538                        self.status_message = Some(format!(
1539                            "Failed to read changelog {}: {err}",
1540                            path.display()
1541                        ));
1542                    }
1543                }
1544                None
1545            }
1546            SlashCommand::Tree => {
1547                if self.agent_state != AgentState::Idle {
1548                    self.status_message = Some("Cannot open tree while processing".to_string());
1549                    return None;
1550                }
1551
1552                let Ok(session_guard) = self.session.try_lock() else {
1553                    self.status_message = Some("Session busy; try again".to_string());
1554                    return None;
1555                };
1556                let initial_selected_id = resolve_tree_selector_initial_id(&session_guard, args);
1557                let selector = TreeSelectorState::new(
1558                    &session_guard,
1559                    self.term_height,
1560                    initial_selected_id.as_deref(),
1561                );
1562                drop(session_guard);
1563                self.tree_ui = Some(TreeUiState::Selector(selector));
1564                None
1565            }
1566            SlashCommand::Fork => self.handle_slash_fork(args),
1567            SlashCommand::Compact => self.handle_slash_compact(args),
1568            SlashCommand::Reload => self.handle_slash_reload(),
1569            SlashCommand::Share => self.handle_slash_share(args),
1570        }
1571    }
1572
1573    #[allow(clippy::too_many_lines)]
1574    pub(super) fn handle_slash_login(&mut self, args: &str) -> Option<Cmd> {
1575        if self.agent_state != AgentState::Idle {
1576            self.status_message = Some("Cannot login while processing".to_string());
1577            return None;
1578        }
1579
1580        let args = args.trim();
1581        if args.is_empty() {
1582            let auth_path = crate::config::Config::auth_path();
1583            match crate::auth::AuthStorage::load(auth_path) {
1584                Ok(auth) => {
1585                    let listing = format_login_provider_listing(&auth, &self.available_models);
1586                    self.messages.push(ConversationMessage {
1587                        role: MessageRole::System,
1588                        content: listing,
1589                        thinking: None,
1590                        collapsed: false,
1591                    });
1592                    self.scroll_to_last_match("Available login providers:");
1593                }
1594                Err(err) => {
1595                    self.status_message = Some(format!("Unable to load auth status: {err}"));
1596                }
1597            }
1598            return None;
1599        }
1600
1601        let requested_provider = args.split_whitespace().next().unwrap_or(args).to_string();
1602        let provider = normalize_auth_provider_input(&requested_provider);
1603
1604        if let Some(prompt) = api_key_login_prompt(&provider) {
1605            self.messages.push(ConversationMessage {
1606                role: MessageRole::System,
1607                content: prompt.to_string(),
1608                thinking: None,
1609                collapsed: false,
1610            });
1611            self.scroll_to_bottom();
1612            self.pending_oauth = Some(PendingOAuth {
1613                provider,
1614                kind: PendingLoginKind::ApiKey,
1615                verifier: String::new(),
1616                oauth_config: None,
1617                device_code: None,
1618            });
1619            self.input_mode = InputMode::SingleLine;
1620            self.set_input_height(3);
1621            self.input.focus();
1622            return None;
1623        }
1624
1625        if provider == "kimi-for-coding" {
1626            let device_start =
1627                futures::executor::block_on(crate::auth::start_kimi_code_device_flow());
1628            match device_start {
1629                Ok(device) => {
1630                    let verification_url = device
1631                        .verification_uri_complete
1632                        .clone()
1633                        .unwrap_or_else(|| device.verification_uri.clone());
1634                    let message = format!(
1635                        "OAuth login: kimi-for-coding\n\n\
1636Open this URL:\n{verification_url}\n\n\
1637If prompted, enter this code: {}\n\
1638Code expires in {} seconds.\n\n\
1639After approving access in the browser, press Enter in Pi to complete login.",
1640                        device.user_code, device.expires_in
1641                    );
1642                    self.messages.push(ConversationMessage {
1643                        role: MessageRole::System,
1644                        content: message,
1645                        thinking: None,
1646                        collapsed: false,
1647                    });
1648                    self.scroll_to_bottom();
1649                    self.pending_oauth = Some(PendingOAuth {
1650                        provider,
1651                        kind: PendingLoginKind::DeviceFlow,
1652                        verifier: String::new(),
1653                        oauth_config: None,
1654                        device_code: Some(device.device_code),
1655                    });
1656                    self.input_mode = InputMode::SingleLine;
1657                    self.set_input_height(3);
1658                    self.input.focus();
1659                }
1660                Err(err) => {
1661                    self.status_message = Some(format!("OAuth login failed: {err}"));
1662                }
1663            }
1664            return None;
1665        }
1666
1667        // Look up OAuth config: built-in providers or extension-registered OAuth config.
1668        let oauth_result = if provider == "anthropic" {
1669            crate::auth::start_anthropic_oauth().map(|info| (info, None))
1670        } else if provider == "openai-codex" {
1671            crate::auth::start_openai_codex_oauth().map(|info| (info, None))
1672        } else if provider == "google-gemini-cli" {
1673            crate::auth::start_google_gemini_cli_oauth().map(|info| (info, None))
1674        } else if provider == "google-antigravity" {
1675            crate::auth::start_google_antigravity_oauth().map(|info| (info, None))
1676        } else if provider == "github-copilot" || provider == "copilot" {
1677            let client_id = std::env::var("GITHUB_COPILOT_CLIENT_ID").unwrap_or_default();
1678            let copilot_config = crate::auth::CopilotOAuthConfig {
1679                client_id,
1680                ..crate::auth::CopilotOAuthConfig::default()
1681            };
1682            crate::auth::start_copilot_browser_oauth(&copilot_config).map(|info| (info, None))
1683        } else if provider == "gitlab" || provider == "gitlab-duo" {
1684            let client_id = std::env::var("GITLAB_CLIENT_ID").unwrap_or_default();
1685            let base_url = std::env::var("GITLAB_BASE_URL")
1686                .unwrap_or_else(|_| "https://gitlab.com".to_string());
1687            let gitlab_config = crate::auth::GitLabOAuthConfig {
1688                client_id,
1689                base_url,
1690                ..crate::auth::GitLabOAuthConfig::default()
1691            };
1692            crate::auth::start_gitlab_oauth(&gitlab_config).map(|info| (info, None))
1693        } else {
1694            // Check extension providers for OAuth config.
1695            let ext_oauth = extension_oauth_config_for_provider(&self.available_models, &provider);
1696            if let Some(config) = ext_oauth {
1697                crate::auth::start_extension_oauth(&provider, &config)
1698                    .map(|info| (info, Some(config)))
1699            } else {
1700                self.status_message = Some(format!(
1701                    "Login not supported for {provider} (no built-in flow or OAuth config)"
1702                ));
1703                return None;
1704            }
1705        };
1706
1707        match oauth_result {
1708            Ok((info, ext_config)) => {
1709                let mut message = format!(
1710                    "OAuth login: {}\n\nOpen this URL:\n{}\n",
1711                    info.provider, info.url
1712                );
1713                if info.provider == "anthropic" {
1714                    message.push_str(
1715                        "\nWARNING: Anthropic OAuth (Claude Code consumer account) is no longer recommended.\n\
1716Using consumer OAuth tokens outside the official client may violate Anthropic's consumer Terms of Service and can\n\
1717result in account suspension/ban. Prefer using an Anthropic API key (ANTHROPIC_API_KEY) instead.\n",
1718                    );
1719                }
1720                if let Some(instructions) = info.instructions {
1721                    message.push('\n');
1722                    message.push_str(&instructions);
1723                    message.push('\n');
1724                }
1725                message.push_str(
1726                    "\nPaste the callback URL or authorization code into Pi to continue.",
1727                );
1728
1729                self.messages.push(ConversationMessage {
1730                    role: MessageRole::System,
1731                    content: message,
1732                    thinking: None,
1733                    collapsed: false,
1734                });
1735                self.scroll_to_bottom();
1736                self.pending_oauth = Some(PendingOAuth {
1737                    provider: info.provider,
1738                    kind: PendingLoginKind::OAuth,
1739                    verifier: info.verifier,
1740                    oauth_config: ext_config,
1741                    device_code: None,
1742                });
1743                self.input_mode = InputMode::SingleLine;
1744                self.set_input_height(3);
1745                self.input.focus();
1746                None
1747            }
1748            Err(err) => {
1749                self.status_message = Some(format!("OAuth login failed: {err}"));
1750                None
1751            }
1752        }
1753    }
1754
1755    pub(super) fn handle_slash_logout(&mut self, args: &str) -> Option<Cmd> {
1756        if self.agent_state != AgentState::Idle {
1757            self.status_message = Some("Cannot logout while processing".to_string());
1758            return None;
1759        }
1760
1761        let requested_provider = if args.is_empty() {
1762            self.model_entry.model.provider.clone()
1763        } else {
1764            args.split_whitespace().next().unwrap_or(args).to_string()
1765        };
1766        let requested_provider = requested_provider.trim().to_ascii_lowercase();
1767        let provider = normalize_auth_provider_input(&requested_provider);
1768
1769        let auth_path = crate::config::Config::auth_path();
1770        match crate::auth::AuthStorage::load(auth_path) {
1771            Ok(mut auth) => {
1772                let removed = remove_provider_credentials(&mut auth, &requested_provider);
1773                if let Err(err) = auth.save() {
1774                    self.status_message = Some(err.to_string());
1775                    return None;
1776                }
1777                self.sync_active_provider_credentials(&provider);
1778                if removed {
1779                    self.status_message =
1780                        Some(format!("Removed stored credentials for {provider}."));
1781                } else {
1782                    self.status_message = Some(format!("No stored credentials for {provider}."));
1783                }
1784            }
1785            Err(err) => {
1786                self.status_message = Some(err.to_string());
1787            }
1788        }
1789        None
1790    }
1791
1792    #[allow(clippy::too_many_lines)]
1793    pub(super) fn handle_slash_model(&mut self, args: &str) -> Option<Cmd> {
1794        if args.trim().is_empty() {
1795            self.open_model_selector_configured_only();
1796            return None;
1797        }
1798
1799        if self.agent_state != AgentState::Idle {
1800            self.status_message = Some("Cannot switch models while processing".to_string());
1801            return None;
1802        }
1803
1804        let pattern = args.trim();
1805        let pattern_lower = pattern.to_ascii_lowercase();
1806        let provider_scoped_pattern = split_provider_model_spec(pattern);
1807
1808        let mut exact_matches = Vec::new();
1809        for entry in &self.available_models {
1810            let full = format!("{}/{}", entry.model.provider, entry.model.id);
1811            if full.eq_ignore_ascii_case(pattern)
1812                || entry.model.id.eq_ignore_ascii_case(pattern)
1813                || provider_scoped_pattern.is_some_and(|(provider, model_id)| {
1814                    provider_ids_match(&entry.model.provider, provider)
1815                        && entry.model.id.eq_ignore_ascii_case(model_id)
1816                })
1817            {
1818                exact_matches.push(entry.clone());
1819            }
1820        }
1821
1822        let mut matches = if exact_matches.is_empty() {
1823            let mut fuzzy = Vec::new();
1824            for entry in &self.available_models {
1825                let full = format!("{}/{}", entry.model.provider, entry.model.id);
1826                let full_lower = full.to_ascii_lowercase();
1827                if full_lower.contains(&pattern_lower)
1828                    || entry.model.id.to_ascii_lowercase().contains(&pattern_lower)
1829                {
1830                    fuzzy.push(entry.clone());
1831                }
1832            }
1833            fuzzy
1834        } else {
1835            exact_matches
1836        };
1837
1838        matches.sort_by(|a, b| {
1839            let left = format!("{}/{}", a.model.provider, a.model.id);
1840            let right = format!("{}/{}", b.model.provider, b.model.id);
1841            left.to_ascii_lowercase().cmp(&right.to_ascii_lowercase())
1842        });
1843        matches.dedup_by(|a, b| model_entry_matches(a, b));
1844
1845        if matches.is_empty()
1846            && let Some((provider, model_id)) = pattern.split_once('/')
1847        {
1848            let provider = normalize_auth_provider_input(provider);
1849            let model_id = model_id.trim();
1850            if !provider.is_empty()
1851                && !model_id.is_empty()
1852                && let Some(entry) = crate::models::ad_hoc_model_entry(&provider, model_id)
1853            {
1854                matches.push(entry);
1855            }
1856        }
1857
1858        if matches.is_empty() {
1859            self.status_message = Some(format!("Model not found: {pattern}"));
1860            return None;
1861        }
1862        if matches.len() > 1 {
1863            let preview = matches
1864                .iter()
1865                .take(8)
1866                .map(|m| format!("  - {}/{}", m.model.provider, m.model.id))
1867                .collect::<Vec<_>>()
1868                .join("\n");
1869            self.messages.push(ConversationMessage {
1870                role: MessageRole::System,
1871                content: format!(
1872                    "Ambiguous model pattern \"{pattern}\". Matches:\n{preview}\n\nUse /model provider/id for an exact match."
1873                ),
1874                thinking: None,
1875                collapsed: false,
1876            });
1877            self.scroll_to_bottom();
1878            return None;
1879        }
1880
1881        let next = matches.into_iter().next().expect("matches is non-empty");
1882
1883        let resolved_key_opt = resolve_model_key_from_default_auth(&next);
1884        if model_requires_configured_credential(&next) && resolved_key_opt.is_none() {
1885            self.status_message = Some(format!(
1886                "Missing credentials for provider {}. Run /login {}.",
1887                next.model.provider, next.model.provider
1888            ));
1889            return None;
1890        }
1891
1892        if model_entry_matches(&next, &self.model_entry) {
1893            self.status_message = Some(format!("Current model: {}", self.model));
1894            return None;
1895        }
1896
1897        let provider_impl = match providers::create_provider(&next, self.extensions.as_ref()) {
1898            Ok(provider_impl) => provider_impl,
1899            Err(err) => {
1900                self.status_message = Some(err.to_string());
1901                return None;
1902            }
1903        };
1904
1905        let Ok(mut agent_guard) = self.agent.try_lock() else {
1906            self.status_message = Some("Agent busy; try again".to_string());
1907            return None;
1908        };
1909        agent_guard.set_provider(provider_impl);
1910        agent_guard
1911            .stream_options_mut()
1912            .api_key
1913            .clone_from(&resolved_key_opt);
1914        agent_guard
1915            .stream_options_mut()
1916            .headers
1917            .clone_from(&next.headers);
1918        drop(agent_guard);
1919
1920        let Ok(mut session_guard) = self.session.try_lock() else {
1921            self.status_message = Some("Session busy; try again".to_string());
1922            return None;
1923        };
1924        session_guard.header.provider = Some(next.model.provider.clone());
1925        session_guard.header.model_id = Some(next.model.id.clone());
1926        session_guard.append_model_change(next.model.provider.clone(), next.model.id.clone());
1927        drop(session_guard);
1928        self.spawn_save_session();
1929
1930        if !self
1931            .available_models
1932            .iter()
1933            .any(|entry| model_entry_matches(entry, &next))
1934        {
1935            self.available_models.push(next.clone());
1936        }
1937        self.model_entry = next.clone();
1938        if let Ok(mut guard) = self.model_entry_shared.lock() {
1939            *guard = next.clone();
1940        }
1941        self.model = format!("{}/{}", next.model.provider, next.model.id);
1942
1943        self.status_message = Some(format!("Switched model: {}", self.model));
1944        None
1945    }
1946
1947    pub(super) fn handle_slash_thinking(&mut self, args: &str) -> Option<Cmd> {
1948        let value = args.trim();
1949        if value.is_empty() {
1950            let current = self
1951                .session
1952                .try_lock()
1953                .ok()
1954                .and_then(|guard| guard.header.thinking_level.clone())
1955                .unwrap_or_else(|| ThinkingLevel::Off.to_string());
1956            self.status_message = Some(format!("Thinking level: {current}"));
1957            return None;
1958        }
1959
1960        let level: ThinkingLevel = match value.parse() {
1961            Ok(level) => level,
1962            Err(err) => {
1963                self.status_message = Some(err);
1964                return None;
1965            }
1966        };
1967
1968        let Ok(mut session_guard) = self.session.try_lock() else {
1969            self.status_message = Some("Session busy; try again".to_string());
1970            return None;
1971        };
1972        session_guard.header.thinking_level = Some(level.to_string());
1973        session_guard.append_thinking_level_change(level.to_string());
1974        drop(session_guard);
1975        self.spawn_save_session();
1976
1977        if let Ok(mut agent_guard) = self.agent.try_lock() {
1978            agent_guard.stream_options_mut().thinking_level = Some(level);
1979        }
1980
1981        self.status_message = Some(format!("Thinking level: {level}"));
1982        None
1983    }
1984
1985    #[allow(clippy::too_many_lines)]
1986    pub(super) fn handle_slash_scoped_models(&mut self, args: &str) -> Option<Cmd> {
1987        let value = args.trim();
1988        if value.is_empty() {
1989            self.messages.push(ConversationMessage {
1990                role: MessageRole::System,
1991                content: self.format_scoped_models_status(),
1992                thinking: None,
1993                collapsed: false,
1994            });
1995            self.scroll_to_last_match("Scoped models");
1996            return None;
1997        }
1998
1999        if value.eq_ignore_ascii_case("clear") {
2000            let previous_patterns = self
2001                .config
2002                .enabled_models
2003                .as_deref()
2004                .unwrap_or(&[])
2005                .to_vec();
2006            self.config.enabled_models = Some(Vec::new());
2007            self.model_scope.clear();
2008
2009            let global_dir = Config::global_dir();
2010            let patch = json!({ "enabled_models": [] });
2011            let cleared_msg = if previous_patterns.is_empty() {
2012                "Scoped models cleared (was: all models)".to_string()
2013            } else {
2014                format!(
2015                    "Scoped models cleared: removed {} pattern(s) (was: {})",
2016                    previous_patterns.len(),
2017                    previous_patterns.join(", ")
2018                )
2019            };
2020            if let Err(err) = Config::patch_settings_with_roots(
2021                SettingsScope::Project,
2022                &global_dir,
2023                &self.cwd,
2024                patch,
2025            ) {
2026                tracing::warn!("Failed to persist enabled_models: {err}");
2027                self.status_message = Some(format!("{cleared_msg} (not saved: {err})"));
2028            } else {
2029                self.status_message = Some(cleared_msg);
2030            }
2031            return None;
2032        }
2033
2034        let patterns = parse_scoped_model_patterns(value);
2035        if patterns.is_empty() {
2036            self.status_message = Some("Usage: /scoped-models [patterns|clear]".to_string());
2037            return None;
2038        }
2039
2040        let resolved = match resolve_scoped_model_entries(&patterns, &self.available_models) {
2041            Ok(resolved) => resolved,
2042            Err(err) => {
2043                self.status_message =
2044                    Some(format!("{err}\n  Example: /scoped-models gpt-4*,claude-3*"));
2045                return None;
2046            }
2047        };
2048
2049        self.model_scope = resolved;
2050        self.config.enabled_models = Some(patterns.clone());
2051
2052        let match_count = self.model_scope.len();
2053
2054        // Build a preview of matched models for the conversation pane.
2055        let mut preview = String::new();
2056        if match_count == 0 {
2057            let _ = writeln!(
2058                preview,
2059                "Warning: No models matched patterns: {}",
2060                patterns.join(", ")
2061            );
2062            let _ = writeln!(preview, "Ctrl+P cycling will use all available models.");
2063        } else {
2064            let _ = writeln!(preview, "Matching {match_count} model(s):");
2065            let mut model_names: Vec<String> = self
2066                .model_scope
2067                .iter()
2068                .map(|e| format!("{}/{}", e.model.provider, e.model.id))
2069                .collect();
2070            model_names.sort_by_key(|s| s.to_ascii_lowercase());
2071            model_names.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
2072            for name in &model_names {
2073                let _ = writeln!(preview, "  {name}");
2074            }
2075        }
2076        let _ = writeln!(
2077            preview,
2078            "Patterns saved. Press Ctrl+P to cycle through matched models."
2079        );
2080
2081        self.messages.push(ConversationMessage {
2082            role: MessageRole::System,
2083            content: preview,
2084            thinking: None,
2085            collapsed: false,
2086        });
2087        self.scroll_to_bottom();
2088
2089        let status = if match_count == 0 {
2090            "Scoped models updated: 0 matched; cycling will use all available models".to_string()
2091        } else {
2092            format!("Scoped models updated: {match_count} matched")
2093        };
2094        let global_dir = Config::global_dir();
2095        let patch = json!({ "enabled_models": patterns });
2096        if let Err(err) =
2097            Config::patch_settings_with_roots(SettingsScope::Project, &global_dir, &self.cwd, patch)
2098        {
2099            tracing::warn!("Failed to persist enabled_models: {err}");
2100            self.status_message = Some(format!("{status} (not saved: {err})"));
2101        } else {
2102            self.status_message = Some(status);
2103        }
2104        None
2105    }
2106
2107    pub(super) fn handle_slash_reload(&mut self) -> Option<Cmd> {
2108        if self.agent_state != AgentState::Idle {
2109            self.status_message = Some("Cannot reload while processing".to_string());
2110            return None;
2111        }
2112
2113        let config = self.config.clone();
2114        let cli = self.resource_cli.clone();
2115        let cwd = self.cwd.clone();
2116        let event_tx = self.event_tx.clone();
2117        let runtime_handle = self.runtime_handle.clone();
2118
2119        runtime_handle.spawn(async move {
2120            let manager = PackageManager::new(cwd.clone());
2121            match ResourceLoader::load(&manager, &cwd, &config, &cli).await {
2122                Ok(resources) => {
2123                    let models_error =
2124                        match crate::auth::AuthStorage::load_async(Config::auth_path()).await {
2125                            Ok(auth) => {
2126                                let models_path = default_models_path(&Config::global_dir());
2127                                let registry = ModelRegistry::load(&auth, Some(models_path));
2128                                registry.error().map(ToString::to_string)
2129                            }
2130                            Err(err) => Some(format!("Failed to load auth.json: {err}")),
2131                        };
2132
2133                    let (diagnostics, diag_count) =
2134                        build_reload_diagnostics(models_error, &resources);
2135
2136                    let mut status = format!(
2137                        "Reloaded resources: {} skills, {} prompts, {} themes",
2138                        resources.skills().len(),
2139                        resources.prompts().len(),
2140                        resources.themes().len()
2141                    );
2142                    if diag_count > 0 {
2143                        let _ = write!(status, " ({diag_count} diagnostics)");
2144                    }
2145
2146                    let _ = event_tx.try_send(PiMsg::ResourcesReloaded {
2147                        resources,
2148                        status,
2149                        diagnostics,
2150                    });
2151                }
2152                Err(err) => {
2153                    let _ = event_tx.try_send(PiMsg::AgentError(format!(
2154                        "Failed to reload resources: {err}"
2155                    )));
2156                }
2157            }
2158        });
2159
2160        self.status_message = Some("Reloading resources...".to_string());
2161        None
2162    }
2163}
2164
2165#[cfg(test)]
2166mod tests {
2167    use super::{parse_bash_command, parse_extension_command, should_show_startup_oauth_hint};
2168    use crate::auth::{AuthCredential, AuthStorage};
2169    use crate::models::ModelEntry;
2170    use crate::provider::{InputType, Model, ModelCost};
2171    use std::collections::{HashMap, HashSet};
2172    use std::time::{SystemTime, UNIX_EPOCH};
2173
2174    fn empty_auth_storage() -> AuthStorage {
2175        let nonce = SystemTime::now()
2176            .duration_since(UNIX_EPOCH)
2177            .expect("system clock before unix epoch")
2178            .as_nanos();
2179        let path = std::env::temp_dir().join(format!("pi_auth_storage_test_{nonce}.json"));
2180        AuthStorage::load(path).expect("load empty auth storage")
2181    }
2182
2183    fn test_model_entry(provider: &str, id: &str) -> ModelEntry {
2184        ModelEntry {
2185            model: Model {
2186                id: id.to_string(),
2187                name: id.to_string(),
2188                api: "openai-responses".to_string(),
2189                provider: provider.to_string(),
2190                base_url: "https://example.test/v1".to_string(),
2191                reasoning: true,
2192                input: vec![InputType::Text],
2193                cost: ModelCost {
2194                    input: 0.0,
2195                    output: 0.0,
2196                    cache_read: 0.0,
2197                    cache_write: 0.0,
2198                },
2199                context_window: 128_000,
2200                max_tokens: 8_192,
2201                headers: HashMap::new(),
2202            },
2203            api_key: Some("test-key".to_string()),
2204            headers: HashMap::new(),
2205            auth_header: true,
2206            compat: None,
2207            oauth_config: None,
2208        }
2209    }
2210
2211    #[test]
2212    fn parse_ext_cmd_basic() {
2213        let result = parse_extension_command("/deploy");
2214        assert_eq!(result, Some(("deploy".to_string(), vec![])));
2215    }
2216
2217    #[test]
2218    fn parse_ext_cmd_with_args() {
2219        let result = parse_extension_command("/deploy staging fast");
2220        assert_eq!(
2221            result,
2222            Some((
2223                "deploy".to_string(),
2224                vec!["staging".to_string(), "fast".to_string()]
2225            ))
2226        );
2227    }
2228
2229    #[test]
2230    fn parse_ext_cmd_builtin_filtered() {
2231        assert!(parse_extension_command("/help").is_none());
2232        assert!(parse_extension_command("/clear").is_none());
2233        assert!(parse_extension_command("/model").is_none());
2234        assert!(parse_extension_command("/exit").is_none());
2235        assert!(parse_extension_command("/compact").is_none());
2236    }
2237
2238    #[test]
2239    fn parse_ext_cmd_no_slash() {
2240        assert!(parse_extension_command("deploy").is_none());
2241        assert!(parse_extension_command("hello world").is_none());
2242    }
2243
2244    #[test]
2245    fn parse_ext_cmd_empty_slash() {
2246        assert!(parse_extension_command("/").is_none());
2247        assert!(parse_extension_command("/  ").is_none());
2248    }
2249
2250    #[test]
2251    fn parse_ext_cmd_whitespace_trimming() {
2252        let result = parse_extension_command("  /deploy  arg1  arg2  ");
2253        assert_eq!(
2254            result,
2255            Some((
2256                "deploy".to_string(),
2257                vec!["arg1".to_string(), "arg2".to_string()]
2258            ))
2259        );
2260    }
2261
2262    #[test]
2263    fn parse_ext_cmd_single_arg() {
2264        let result = parse_extension_command("/greet world");
2265        assert_eq!(
2266            result,
2267            Some(("greet".to_string(), vec!["world".to_string()]))
2268        );
2269    }
2270
2271    #[test]
2272    fn parse_bash_command_distinguishes_exclusion() {
2273        let (command, exclude) = parse_bash_command("! ls -la").expect("bang command");
2274        assert_eq!(command, "ls -la");
2275        assert!(!exclude);
2276
2277        let (command, exclude) = parse_bash_command("!! ls -la").expect("double bang command");
2278        assert_eq!(command, "ls -la");
2279        assert!(exclude);
2280    }
2281
2282    #[test]
2283    fn parse_bash_command_empty_bang() {
2284        assert!(parse_bash_command("!").is_none());
2285        assert!(parse_bash_command("!!").is_none());
2286        assert!(parse_bash_command("!  ").is_none());
2287    }
2288
2289    #[test]
2290    fn parse_bash_command_no_bang() {
2291        assert!(parse_bash_command("ls -la").is_none());
2292        assert!(parse_bash_command("").is_none());
2293    }
2294
2295    #[test]
2296    fn parse_bash_command_leading_whitespace() {
2297        let (cmd, exclude) = parse_bash_command("  ! echo hi").expect("should parse");
2298        assert_eq!(cmd, "echo hi");
2299        assert!(!exclude);
2300    }
2301
2302    #[test]
2303    fn startup_hint_is_hidden_when_priority_provider_is_available() {
2304        let mut auth = empty_auth_storage();
2305        auth.set(
2306            "anthropic",
2307            AuthCredential::ApiKey {
2308                key: "test-key".to_string(),
2309            },
2310        );
2311        assert!(!should_show_startup_oauth_hint(&auth));
2312    }
2313
2314    #[test]
2315    fn startup_hint_is_hidden_when_non_oauth_provider_is_available() {
2316        let mut auth = empty_auth_storage();
2317        auth.set(
2318            "openai",
2319            AuthCredential::ApiKey {
2320                key: "test-openai-key".to_string(),
2321            },
2322        );
2323        assert!(!should_show_startup_oauth_hint(&auth));
2324    }
2325
2326    #[test]
2327    fn startup_hint_copy_no_longer_uses_front_and_center_phrase() {
2328        let auth = empty_auth_storage();
2329        let hint = super::format_startup_oauth_hint(&auth);
2330        assert!(hint.contains("No provider credentials were detected."));
2331        assert!(!hint.contains("front and center"));
2332    }
2333
2334    #[test]
2335    fn builtin_login_providers_cover_legacy_oauth_registry() {
2336        let login_oauth: HashSet<&str> = super::BUILTIN_LOGIN_PROVIDERS
2337            .iter()
2338            .filter_map(|(provider, mode)| (*mode == "OAuth").then_some(*provider))
2339            .collect();
2340
2341        // Legacy pi-mono OAuth provider registry (packages/ai/src/utils/oauth/index.ts)
2342        // includes exactly these built-ins.
2343        let legacy_oauth = [
2344            "anthropic",
2345            "openai-codex",
2346            "google-gemini-cli",
2347            "google-antigravity",
2348            "github-copilot",
2349        ];
2350
2351        let missing: Vec<&str> = legacy_oauth
2352            .iter()
2353            .copied()
2354            .filter(|provider| !login_oauth.contains(provider))
2355            .collect();
2356
2357        assert!(
2358            missing.is_empty(),
2359            "missing legacy OAuth providers in /login table: {}",
2360            missing.join(", ")
2361        );
2362
2363        assert!(
2364            login_oauth.contains("kimi-for-coding"),
2365            "kimi-for-coding should remain available in /login OAuth providers"
2366        );
2367    }
2368
2369    #[test]
2370    fn model_entry_matches_provider_aliases_case_insensitively() {
2371        let left = test_model_entry("openrouter", "openai/gpt-4o-mini");
2372        let right = test_model_entry("open-router", "openai/gpt-4o-mini");
2373        assert!(super::model_entry_matches(&left, &right));
2374    }
2375
2376    #[test]
2377    fn provider_ids_match_normalizes_aliases() {
2378        assert!(super::provider_ids_match("openrouter", "open-router"));
2379        assert!(super::provider_ids_match("google-gemini-cli", "gemini-cli"));
2380        assert!(super::provider_ids_match("kimi-for-coding", "kimi-code"));
2381        assert!(!super::provider_ids_match("openai", "anthropic"));
2382    }
2383
2384    #[test]
2385    fn normalize_auth_provider_input_maps_kimi_code_alias() {
2386        assert_eq!(
2387            super::normalize_auth_provider_input("kimi-code"),
2388            "kimi-for-coding"
2389        );
2390    }
2391
2392    #[test]
2393    fn resolve_scoped_model_entries_dedupes_provider_alias_variants() {
2394        let available = vec![
2395            test_model_entry("openrouter", "openai/gpt-4o-mini"),
2396            test_model_entry("open-router", "openai/gpt-4o-mini"),
2397        ];
2398        let patterns = vec!["openrouter/openai/gpt-4o-mini".to_string()];
2399        let resolved = super::resolve_scoped_model_entries(&patterns, &available)
2400            .expect("resolve scoped models");
2401        assert_eq!(resolved.len(), 1);
2402        assert_eq!(resolved[0].model.id, "openai/gpt-4o-mini");
2403    }
2404
2405    #[test]
2406    fn save_provider_credential_canonicalizes_alias_input() {
2407        let mut auth = empty_auth_storage();
2408        super::save_provider_credential(
2409            &mut auth,
2410            "gemini",
2411            AuthCredential::ApiKey {
2412                key: "new-google-key".to_string(),
2413            },
2414        );
2415
2416        assert!(auth.get("gemini").is_none());
2417        match auth.get("google") {
2418            Some(AuthCredential::ApiKey { key }) => assert_eq!(key, "new-google-key"),
2419            other => panic!("expected google api key credential, got: {other:?}"),
2420        }
2421    }
2422
2423    #[test]
2424    fn resolve_model_key_with_auth_prefers_stored_key_over_inline_key() {
2425        let mut auth = empty_auth_storage();
2426        auth.set(
2427            "openai",
2428            AuthCredential::ApiKey {
2429                key: "stored-auth-key".to_string(),
2430            },
2431        );
2432
2433        let mut entry = test_model_entry("openai", "gpt-4o-mini");
2434        entry.api_key = Some("inline-model-key".to_string());
2435
2436        assert_eq!(
2437            super::resolve_model_key_with_auth(&auth, &entry).as_deref(),
2438            Some("stored-auth-key")
2439        );
2440    }
2441
2442    #[test]
2443    fn resolve_model_key_with_auth_falls_back_to_inline_key() {
2444        let auth = empty_auth_storage();
2445        let mut entry = test_model_entry("openai", "gpt-4o-mini");
2446        entry.api_key = Some("inline-model-key".to_string());
2447
2448        assert_eq!(
2449            super::resolve_model_key_with_auth(&auth, &entry).as_deref(),
2450            Some("inline-model-key")
2451        );
2452    }
2453
2454    #[test]
2455    fn remove_provider_credentials_removes_alias_entries() {
2456        let mut auth = empty_auth_storage();
2457        auth.set(
2458            "google",
2459            AuthCredential::ApiKey {
2460                key: "google-key".to_string(),
2461            },
2462        );
2463        auth.set(
2464            "gemini",
2465            AuthCredential::ApiKey {
2466                key: "gemini-key".to_string(),
2467            },
2468        );
2469
2470        assert!(super::remove_provider_credentials(&mut auth, "gemini"));
2471        assert!(auth.get("google").is_none());
2472        assert!(auth.get("gemini").is_none());
2473    }
2474
2475    #[test]
2476    fn extension_oauth_config_selection_skips_non_oauth_entries() {
2477        let mut no_oauth = test_model_entry("ext-provider", "model-a");
2478        no_oauth.oauth_config = None;
2479        let mut with_oauth = test_model_entry("ext-provider", "model-b");
2480        with_oauth.oauth_config = Some(crate::models::OAuthConfig {
2481            auth_url: "https://example.test/oauth/authorize".to_string(),
2482            token_url: "https://example.test/oauth/token".to_string(),
2483            scopes: vec!["scope:a".to_string()],
2484            client_id: "client-id".to_string(),
2485            redirect_uri: Some("http://localhost/callback".to_string()),
2486        });
2487
2488        let selected =
2489            super::extension_oauth_config_for_provider(&[no_oauth, with_oauth], "ext-provider");
2490        let selected = selected.expect("expected oauth config");
2491        assert_eq!(selected.auth_url, "https://example.test/oauth/authorize");
2492        assert_eq!(selected.token_url, "https://example.test/oauth/token");
2493        assert_eq!(selected.client_id, "client-id");
2494        assert_eq!(selected.scopes, vec!["scope:a".to_string()]);
2495        assert_eq!(
2496            selected.redirect_uri.as_deref(),
2497            Some("http://localhost/callback")
2498        );
2499    }
2500}