Skip to main content

pi/interactive/
commands.rs

1use super::*;
2
3use crate::models::{ModelEntry, model_requires_configured_credential, normalize_api_key_opt};
4use crate::provider_metadata::{
5    ProviderMetadata, ProviderOnboardingMode, provider_ids_match, provider_metadata,
6    split_provider_model_spec,
7};
8
9#[cfg(feature = "clipboard")]
10use arboard::Clipboard as ArboardClipboard;
11
12/// Available slash commands.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum SlashCommand {
15    Help,
16    Login,
17    Logout,
18    Clear,
19    Model,
20    Thinking,
21    ScopedModels,
22    Exit,
23    History,
24    Export,
25    Session,
26    Settings,
27    Theme,
28    Resume,
29    New,
30    Copy,
31    Name,
32    Hotkeys,
33    Changelog,
34    Tree,
35    Fork,
36    Compact,
37    Reload,
38    Template,
39    Share,
40}
41
42impl SlashCommand {
43    /// Parse a slash command from input.
44    pub fn parse(input: &str) -> Option<(Self, &str)> {
45        let input = input.trim();
46        if !input.starts_with('/') {
47            return None;
48        }
49
50        let (cmd, args) = input.split_once(char::is_whitespace).unwrap_or((input, ""));
51
52        let command = match cmd.to_lowercase().as_str() {
53            "/help" | "/h" | "/?" => Self::Help,
54            "/login" => Self::Login,
55            "/logout" => Self::Logout,
56            "/clear" | "/cls" => Self::Clear,
57            "/model" | "/m" => Self::Model,
58            "/thinking" | "/think" | "/t" => Self::Thinking,
59            "/scoped-models" | "/scoped" => Self::ScopedModels,
60            "/exit" | "/quit" | "/q" => Self::Exit,
61            "/history" | "/hist" => Self::History,
62            "/export" => Self::Export,
63            "/session" | "/info" => Self::Session,
64            "/settings" => Self::Settings,
65            "/theme" => Self::Theme,
66            "/resume" | "/r" => Self::Resume,
67            "/new" => Self::New,
68            "/copy" | "/cp" => Self::Copy,
69            "/name" => Self::Name,
70            "/hotkeys" | "/keys" | "/keybindings" => Self::Hotkeys,
71            "/changelog" => Self::Changelog,
72            "/tree" => Self::Tree,
73            "/fork" => Self::Fork,
74            "/compact" => Self::Compact,
75            "/reload" => Self::Reload,
76            "/template" => Self::Template,
77            "/share" => Self::Share,
78            _ => return None,
79        };
80
81        Some((command, args.trim()))
82    }
83
84    /// Get help text for all commands.
85    pub const fn help_text() -> &'static str {
86        r"Available commands:
87  /help, /h, /?      - Show this help message
88  /login [provider]  - Login/setup credentials; without provider shows status table
89  /logout [provider] - Remove stored credentials
90  /clear, /cls       - Clear conversation history
91  /model, /m [id|provider/id] - Open model selector or switch directly
92  /thinking, /t [level] - Set thinking level (off/minimal/low/medium/high/xhigh)
93  /scoped-models [patterns|clear] - Show or set scoped models for cycling
94  /history, /hist    - Show input history
95  /export [path]     - Export conversation to HTML
96  /session, /info    - Show session info (path, tokens, cost)
97  /settings          - Open settings selector
98  /theme [name]      - List or switch themes (dark/light/custom)
99  /resume, /r        - Pick and resume a previous session
100  /new               - Start a new session
101  /copy, /cp         - Copy last assistant message to clipboard
102  /name <name>       - Set session display name
103  /hotkeys, /keys    - Show keyboard shortcuts
104  /changelog         - Show changelog entries
105  /tree              - Show session branch tree summary
106  /fork [id|index]   - Fork from a user message (default: last on current path)
107  /compact [notes]   - Compact older context with optional instructions
108  /reload            - Reload skills/prompts from disk
109  /template <name> [args] - Expand a prompt template by name
110  /share             - Upload session HTML to a secret GitHub gist and show URL
111  /exit, /quit, /q   - Exit Pi
112
113  Tips:
114    • Use ↑/↓ arrows to navigate input history
115    • Use Ctrl+L to open model selector
116    • Use Ctrl+P to cycle scoped models
117    • Use Shift+Enter (Ctrl+Enter on Windows) to insert a newline
118    • Use PageUp/PageDown to scroll conversation history
119    • Use Escape to cancel current input
120    • Use /skill:name or /template to expand resources"
121    }
122}
123
124pub(super) fn parse_extension_command(input: &str) -> Option<(String, &str)> {
125    let input = input.trim();
126    if !input.starts_with('/') {
127        return None;
128    }
129
130    // Built-in slash commands are handled elsewhere.
131    if SlashCommand::parse(input).is_some() {
132        return None;
133    }
134
135    let (cmd, rest) = input.split_once(char::is_whitespace).unwrap_or((input, ""));
136    let cmd = cmd.trim_start_matches('/').trim();
137    if cmd.is_empty() {
138        return None;
139    }
140    Some((cmd.to_string(), rest.trim()))
141}
142
143pub(super) fn parse_bash_command(input: &str) -> Option<(String, bool)> {
144    let trimmed = input.trim_start();
145    let (rest, force) = trimmed
146        .strip_prefix("!!")
147        .map(|r| (r, true))
148        .or_else(|| trimmed.strip_prefix('!').map(|r| (r, false)))?;
149    let command = rest.trim();
150    if command.is_empty() {
151        return None;
152    }
153    Some((command.to_string(), force))
154}
155
156pub(super) fn normalize_api_key_input(raw: &str) -> std::result::Result<String, String> {
157    let key = raw.trim();
158    if key.is_empty() {
159        return Err("API key cannot be empty".to_string());
160    }
161    if key.chars().any(char::is_whitespace) {
162        return Err("API key must not contain whitespace".to_string());
163    }
164    Ok(key.to_string())
165}
166
167pub(super) fn normalize_auth_provider_input(raw: &str) -> String {
168    let provider = raw.trim().to_ascii_lowercase();
169    crate::provider_metadata::canonical_provider_id(&provider)
170        .unwrap_or(provider.as_str())
171        .to_string()
172}
173
174fn provider_has_dedicated_login_flow(provider: &str) -> bool {
175    BUILTIN_LOGIN_PROVIDERS
176        .iter()
177        .any(|(builtin, _)| provider_ids_match(builtin, provider))
178}
179
180fn provider_supports_interactive_api_key_login(metadata: &ProviderMetadata) -> bool {
181    if metadata.auth_env_keys.is_empty() || provider_has_dedicated_login_flow(metadata.canonical_id)
182    {
183        return false;
184    }
185
186    match metadata.onboarding {
187        ProviderOnboardingMode::OpenAICompatiblePreset => metadata.routing_defaults.is_some(),
188        ProviderOnboardingMode::BuiltInNative => metadata
189            .routing_defaults
190            .is_some_and(|defaults| !defaults.base_url.is_empty()),
191        ProviderOnboardingMode::NativeAdapterRequired => false,
192    }
193}
194
195fn generic_api_key_login_prompt(metadata: &ProviderMetadata) -> String {
196    let provider = metadata.canonical_id;
197    let label = metadata.display_name.unwrap_or(provider);
198    let mut prompt = format!(
199        "API key login: {provider}\n\n\
200Paste your {label} API key to save it in auth.json under {provider}.\n"
201    );
202
203    if let Some(defaults) = metadata.routing_defaults
204        && !defaults.base_url.is_empty()
205    {
206        let _ = writeln!(prompt, "Default base URL: {}", defaults.base_url);
207    }
208
209    if !metadata.auth_env_keys.is_empty() {
210        let _ = writeln!(
211            prompt,
212            "Accepted env vars: {}",
213            metadata.auth_env_keys.join(", ")
214        );
215    }
216
217    prompt
218        .push_str("\nYour input will be treated as sensitive and is not added to message history.");
219    prompt
220}
221
222pub(super) fn api_key_login_prompt(provider: &str) -> Option<String> {
223    match provider {
224        "openai" => Some(String::from(
225            "API key login: openai\n\n\
226Paste your OpenAI API key to save it in auth.json.\n\
227Get a key from platform.openai.com/api-keys.\n\
228Rotate/revoke keys from that dashboard if compromised.\n\n\
229Your input will be treated as sensitive and is not added to message history.",
230        )),
231        "google" => Some(String::from(
232            "API key login: google/gemini\n\n\
233Paste your Google Gemini API key to save it in auth.json under google.\n\
234Get a key from ai.google.dev/gemini-api/docs/api-key.\n\
235Rotate/revoke keys from Google AI Studio if compromised.\n\n\
236Your input will be treated as sensitive and is not added to message history.",
237        )),
238        _ => provider_metadata(provider)
239            .filter(|metadata| provider_supports_interactive_api_key_login(metadata))
240            .map(generic_api_key_login_prompt),
241    }
242}
243
244pub(super) fn save_provider_credential(
245    auth: &mut crate::auth::AuthStorage,
246    provider: &str,
247    credential: crate::auth::AuthCredential,
248) {
249    let requested = provider.trim().to_ascii_lowercase();
250    let canonical = normalize_auth_provider_input(&requested);
251    let _ = auth.remove_provider_aliases(&requested);
252    if requested != canonical {
253        let _ = auth.remove_provider_aliases(&canonical);
254    }
255    auth.set(canonical.clone(), credential);
256}
257
258pub(super) fn remove_provider_credentials(
259    auth: &mut crate::auth::AuthStorage,
260    requested_provider: &str,
261) -> bool {
262    let requested = requested_provider.trim().to_ascii_lowercase();
263    let canonical = normalize_auth_provider_input(&requested);
264
265    let mut removed = auth.remove_provider_aliases(&canonical);
266    if requested != canonical {
267        removed |= auth.remove_provider_aliases(&requested);
268    }
269    removed
270}
271
272const BUILTIN_LOGIN_PROVIDERS: [(&str, &str); 7] = [
273    ("anthropic", "OAuth"),
274    ("openai-codex", "OAuth"),
275    ("google-gemini-cli", "OAuth"),
276    ("google-antigravity", "OAuth"),
277    ("kimi-for-coding", "OAuth"),
278    ("github-copilot", "OAuth"),
279    ("gitlab", "OAuth"),
280];
281
282const STARTUP_PRIORITY_OAUTH_PROVIDERS: [(&str, &str); 3] = [
283    ("anthropic", "Claude Code"),
284    ("openai-codex", "Codex"),
285    ("google-gemini-cli", "Gemini CLI"),
286];
287
288fn format_compact_duration(ms: i64) -> String {
289    let seconds = (ms.max(0) / 1000).max(1);
290    if seconds < 60 {
291        format!("{seconds}s")
292    } else if seconds < 60 * 60 {
293        format!("{}m", seconds / 60)
294    } else if seconds < 24 * 60 * 60 {
295        format!("{}h", seconds / (60 * 60))
296    } else {
297        format!("{}d", seconds / (24 * 60 * 60))
298    }
299}
300
301fn format_credential_status(status: &crate::auth::CredentialStatus) -> String {
302    match status {
303        crate::auth::CredentialStatus::Missing => "Not authenticated".to_string(),
304        crate::auth::CredentialStatus::ApiKey
305        | crate::auth::CredentialStatus::BearerToken
306        | crate::auth::CredentialStatus::AwsCredentials
307        | crate::auth::CredentialStatus::ServiceKey => "Authenticated".to_string(),
308        crate::auth::CredentialStatus::OAuthValid { expires_in_ms } => {
309            format!(
310                "Authenticated (expires in {})",
311                format_compact_duration(*expires_in_ms)
312            )
313        }
314        crate::auth::CredentialStatus::OAuthExpired { expired_by_ms } => {
315            format!(
316                "Authenticated (expired {} ago)",
317                format_compact_duration(*expired_by_ms)
318            )
319        }
320    }
321}
322
323fn format_provider_status(auth: &crate::auth::AuthStorage, provider: &str) -> String {
324    if let Some(source) = auth.external_setup_source(provider)
325        && !auth.has_stored_credential(provider)
326    {
327        return format!("Auto-detected from {source}");
328    }
329
330    let status = auth.credential_status(provider);
331    format_credential_status(&status)
332}
333
334fn collect_extension_oauth_providers(available_models: &[ModelEntry]) -> Vec<String> {
335    let mut providers: Vec<String> = available_models
336        .iter()
337        .filter(|entry| entry.oauth_config.is_some())
338        .map(|entry| {
339            let provider = entry.model.provider.as_str();
340            crate::provider_metadata::canonical_provider_id(provider)
341                .unwrap_or(provider)
342                .to_string()
343        })
344        .collect();
345
346    providers.retain(|provider| {
347        !BUILTIN_LOGIN_PROVIDERS
348            .iter()
349            .any(|(builtin, _)| provider == builtin)
350    });
351    providers.sort_unstable();
352    providers.dedup();
353    providers
354}
355
356fn extension_oauth_config_for_provider(
357    available_models: &[ModelEntry],
358    provider: &str,
359) -> Option<crate::models::OAuthConfig> {
360    available_models.iter().find_map(|entry| {
361        let model_provider = entry.model.provider.as_str();
362        let canonical = crate::provider_metadata::canonical_provider_id(model_provider)
363            .unwrap_or(model_provider);
364        if canonical.eq_ignore_ascii_case(provider) {
365            entry.oauth_config.clone()
366        } else {
367            None
368        }
369    })
370}
371
372fn append_provider_rows(output: &mut String, heading: &str, rows: &[(String, String, String)]) {
373    let provider_width = rows
374        .iter()
375        .map(|(provider, _, _)| provider.len())
376        .max()
377        .unwrap_or("provider".len())
378        .max("provider".len());
379    let method_width = rows
380        .iter()
381        .map(|(_, method, _)| method.len())
382        .max()
383        .unwrap_or("method".len())
384        .max("method".len());
385
386    let _ = writeln!(output, "{heading}:");
387    let _ = writeln!(
388        output,
389        "  {:<provider_width$}  {:<method_width$}  status",
390        "provider", "method"
391    );
392    for (provider, method, status) in rows {
393        let _ = writeln!(
394            output,
395            "  {provider:<provider_width$}  {method:<method_width$}  {status}"
396        );
397    }
398}
399
400pub(super) fn format_login_provider_listing(
401    auth: &crate::auth::AuthStorage,
402    available_models: &[ModelEntry],
403) -> String {
404    let mut output = String::from("Available login providers:\n\n");
405
406    let mut built_in_rows: Vec<(String, String, String)> = BUILTIN_LOGIN_PROVIDERS
407        .iter()
408        .map(|(provider, method)| {
409            (
410                (*provider).to_string(),
411                (*method).to_string(),
412                format_provider_status(auth, provider),
413            )
414        })
415        .collect();
416    let mut api_key_rows: Vec<(String, String, String)> =
417        crate::provider_metadata::PROVIDER_METADATA
418            .iter()
419            .filter(|meta| provider_supports_interactive_api_key_login(meta))
420            .map(|meta| {
421                let provider = meta.canonical_id.to_string();
422                (
423                    provider.clone(),
424                    "API key".to_string(),
425                    format_provider_status(auth, &provider),
426                )
427            })
428            .collect();
429    api_key_rows.sort_by(|left, right| left.0.cmp(&right.0));
430    built_in_rows.extend(api_key_rows);
431    append_provider_rows(&mut output, "Built-in", &built_in_rows);
432
433    let extension_providers = collect_extension_oauth_providers(available_models);
434    if !extension_providers.is_empty() {
435        let extension_rows: Vec<(String, String, String)> = extension_providers
436            .iter()
437            .map(|provider| {
438                (
439                    provider.clone(),
440                    "OAuth".to_string(),
441                    format_provider_status(auth, provider),
442                )
443            })
444            .collect();
445        output.push('\n');
446        append_provider_rows(&mut output, "Extension providers", &extension_rows);
447    }
448
449    output.push_str("\nUsage: /login <provider>");
450    output
451}
452
453pub(super) fn format_startup_oauth_hint(auth: &crate::auth::AuthStorage) -> String {
454    let mut output = String::new();
455    output.push_str("  No provider credentials were detected.\n");
456    output.push_str("  Connect one of these providers:\n");
457    for (provider, label) in STARTUP_PRIORITY_OAUTH_PROVIDERS {
458        let status = format_provider_status(auth, provider);
459        let _ = writeln!(output, "  - {provider} ({label}): {status}");
460    }
461    output.push_str("  Use /login <provider> to connect or refresh credentials.\n");
462    output.push_str("  Use /login to see all providers and auth methods.");
463    output
464}
465
466pub(super) fn should_show_startup_oauth_hint(auth: &crate::auth::AuthStorage) -> bool {
467    let has_any_credential = crate::provider_metadata::PROVIDER_METADATA
468        .iter()
469        .map(|meta| meta.canonical_id)
470        .any(|provider| {
471            auth.has_stored_credential(provider)
472                || auth.external_setup_source(provider).is_some()
473                || auth.resolve_api_key(provider, None).is_some()
474        });
475    if has_any_credential {
476        return false;
477    }
478
479    STARTUP_PRIORITY_OAUTH_PROVIDERS
480        .iter()
481        .all(|(provider, _)| {
482            auth.resolve_api_key(provider, None).is_none()
483                && !auth.has_stored_credential(provider)
484                && auth.external_setup_source(provider).is_none()
485        })
486}
487
488pub fn strip_thinking_level_suffix(pattern: &str) -> &str {
489    let Some((prefix, suffix)) = pattern.rsplit_once(':') else {
490        return pattern;
491    };
492    match suffix.to_ascii_lowercase().as_str() {
493        "off" | "minimal" | "low" | "medium" | "high" | "xhigh" => prefix,
494        _ => pattern,
495    }
496}
497
498pub fn parse_scoped_model_patterns(args: &str) -> Vec<String> {
499    args.split(|c: char| c == ',' || c.is_whitespace())
500        .map(str::trim)
501        .filter(|s| !s.is_empty())
502        .map(ToString::to_string)
503        .collect()
504}
505
506pub fn model_entry_matches(left: &ModelEntry, right: &ModelEntry) -> bool {
507    let left_provider = crate::provider_metadata::canonical_provider_id(&left.model.provider)
508        .unwrap_or(&left.model.provider);
509    let right_provider = crate::provider_metadata::canonical_provider_id(&right.model.provider)
510        .unwrap_or(&right.model.provider);
511
512    left_provider.eq_ignore_ascii_case(right_provider)
513        && left.model.id.eq_ignore_ascii_case(&right.model.id)
514}
515
516pub(super) fn resolve_model_key_with_auth(
517    auth: &crate::auth::AuthStorage,
518    entry: &ModelEntry,
519) -> Option<String> {
520    normalize_api_key_opt(auth.resolve_api_key(&entry.model.provider, None))
521        .or_else(|| normalize_api_key_opt(entry.api_key.clone()))
522}
523
524pub(super) fn resolve_model_key_from_default_auth(entry: &ModelEntry) -> Option<String> {
525    let auth_path = crate::config::Config::auth_path();
526    crate::auth::AuthStorage::load(auth_path)
527        .ok()
528        .and_then(|auth| resolve_model_key_with_auth(&auth, entry))
529        .or_else(|| normalize_api_key_opt(entry.api_key.clone()))
530}
531
532fn session_thinking_level(
533    session: &crate::session::Session,
534) -> Option<crate::model::ThinkingLevel> {
535    session
536        .effective_thinking_level_for_current_path()
537        .as_deref()
538        .and_then(|value| value.parse::<crate::model::ThinkingLevel>().ok())
539}
540
541fn model_entry_event_payload(entry: &ModelEntry) -> Value {
542    json!({
543        "id": entry.model.id.clone(),
544        "name": entry.model.name.clone(),
545        "provider": entry.model.provider.clone(),
546        "api": entry.model.api.clone(),
547        "baseUrl": entry.model.base_url.clone(),
548        "contextWindow": entry.model.context_window,
549        "maxTokens": entry.model.max_tokens,
550        "input": &entry.model.input,
551    })
552}
553
554#[derive(Debug, Clone, Copy, PartialEq, Eq)]
555struct SessionThinkingSyncPlan {
556    effective: crate::model::ThinkingLevel,
557    thinking_changed: bool,
558    persist_needed: bool,
559}
560
561fn plan_session_thinking_sync(
562    session_thinking: Option<&str>,
563    current_thinking: crate::model::ThinkingLevel,
564    target_entry: &ModelEntry,
565) -> SessionThinkingSyncPlan {
566    let parsed_session_thinking = session_thinking.and_then(|raw| {
567        raw.parse::<crate::model::ThinkingLevel>().map_or_else(
568            |_| {
569                tracing::warn!("Ignoring invalid session thinking level: {raw}");
570                None
571            },
572            Some,
573        )
574    });
575    let requested_thinking = parsed_session_thinking.unwrap_or(current_thinking);
576    let effective = target_entry.clamp_thinking_level(requested_thinking);
577    let thinking_changed = effective != current_thinking;
578    let persist_needed = if session_thinking.is_some() {
579        parsed_session_thinking != Some(effective)
580    } else {
581        thinking_changed
582    };
583
584    SessionThinkingSyncPlan {
585        effective,
586        thinking_changed,
587        persist_needed,
588    }
589}
590
591fn parse_user_bash_event_result(value: &Value) -> Option<crate::tools::BashRunResult> {
592    let result = value
593        .as_object()
594        .map_or(value, |obj| obj.get("result").unwrap_or(value));
595    let obj = result.as_object()?;
596
597    let output = obj
598        .get("output")
599        .and_then(Value::as_str)
600        .unwrap_or("")
601        .to_string();
602    let exit_code = obj
603        .get("exitCode")
604        .and_then(Value::as_i64)
605        .or_else(|| obj.get("exit_code").and_then(Value::as_i64))
606        .unwrap_or(0);
607    let cancelled = obj
608        .get("cancelled")
609        .and_then(Value::as_bool)
610        .unwrap_or(false);
611    let truncated = obj
612        .get("truncated")
613        .and_then(Value::as_bool)
614        .unwrap_or(false);
615    let full_output_path = obj
616        .get("fullOutputPath")
617        .or_else(|| obj.get("full_output_path"))
618        .and_then(Value::as_str)
619        .map(ToString::to_string);
620
621    Some(crate::tools::BashRunResult {
622        output,
623        exit_code: i32::try_from(exit_code).unwrap_or(0),
624        cancelled,
625        truncated,
626        full_output_path,
627        truncation: None,
628    })
629}
630
631pub fn resolve_scoped_model_entries(
632    patterns: &[String],
633    available_models: &[ModelEntry],
634) -> Result<Vec<ModelEntry>, String> {
635    let mut resolved: Vec<ModelEntry> = Vec::new();
636
637    for pattern in patterns {
638        let raw_pattern = strip_thinking_level_suffix(pattern);
639        let is_glob =
640            raw_pattern.contains('*') || raw_pattern.contains('?') || raw_pattern.contains('[');
641
642        if is_glob {
643            let glob = Pattern::new(&raw_pattern.to_lowercase())
644                .map_err(|err| format!("Invalid model pattern \"{pattern}\": {err}"))?;
645
646            for entry in available_models {
647                let full_id = format!("{}/{}", entry.model.provider, entry.model.id);
648                let full_id_lower = full_id.to_lowercase();
649                let id_lower = entry.model.id.to_lowercase();
650
651                if (glob.matches(&full_id_lower) || glob.matches(&id_lower))
652                    && !resolved
653                        .iter()
654                        .any(|existing| model_entry_matches(existing, entry))
655                {
656                    resolved.push(entry.clone());
657                }
658            }
659            continue;
660        }
661
662        for entry in available_models {
663            let full_id = format!("{}/{}", entry.model.provider, entry.model.id);
664            if raw_pattern.eq_ignore_ascii_case(&full_id)
665                || raw_pattern.eq_ignore_ascii_case(&entry.model.id)
666            {
667                if !resolved
668                    .iter()
669                    .any(|existing| model_entry_matches(existing, entry))
670                {
671                    resolved.push(entry.clone());
672                }
673                break;
674            }
675        }
676    }
677
678    resolved.sort_by(|a, b| {
679        let left = format!("{}/{}", a.model.provider, a.model.id);
680        let right = format!("{}/{}", b.model.provider, b.model.id);
681        left.cmp(&right)
682    });
683
684    Ok(resolved)
685}
686
687pub(super) const fn kind_rank(kind: &DiagnosticKind) -> u8 {
688    match kind {
689        DiagnosticKind::Warning => 0,
690        DiagnosticKind::Collision => 1,
691    }
692}
693
694pub(super) fn format_resource_diagnostics(
695    label: &str,
696    diagnostics: &[ResourceDiagnostic],
697) -> (String, usize) {
698    let mut ordered: Vec<&ResourceDiagnostic> = diagnostics.iter().collect();
699    ordered.sort_by(|a, b| {
700        a.path
701            .cmp(&b.path)
702            .then_with(|| kind_rank(&a.kind).cmp(&kind_rank(&b.kind)))
703            .then_with(|| a.message.cmp(&b.message))
704    });
705
706    let mut out = String::new();
707    let _ = writeln!(out, "{label}:");
708    for diag in ordered {
709        let kind = match diag.kind {
710            DiagnosticKind::Warning => "warning",
711            DiagnosticKind::Collision => "collision",
712        };
713        let _ = write!(out, "- {kind}: {} ({})", diag.message, diag.path.display());
714        if let Some(collision) = &diag.collision {
715            let _ = write!(
716                out,
717                " [winner: {} loser: {}]",
718                collision.winner_path.display(),
719                collision.loser_path.display()
720            );
721        }
722        out.push('\n');
723    }
724    (out, diagnostics.len())
725}
726
727fn build_reload_diagnostics(
728    models_error: Option<String>,
729    resources: &ResourceLoader,
730) -> (Option<String>, usize) {
731    let mut sections = Vec::new();
732    let mut count = 0usize;
733
734    if let Some(err) = models_error {
735        count = count.saturating_add(1);
736        sections.push(format!("models.json:\n{err}"));
737    }
738
739    let mut resource_sections = Vec::new();
740    let (skills_text, skills_count) =
741        format_resource_diagnostics("Skills", resources.skill_diagnostics());
742    if skills_count > 0 {
743        resource_sections.push(skills_text);
744        count = count.saturating_add(skills_count);
745    }
746
747    let (prompts_text, prompts_count) =
748        format_resource_diagnostics("Prompts", resources.prompt_diagnostics());
749    if prompts_count > 0 {
750        resource_sections.push(prompts_text);
751        count = count.saturating_add(prompts_count);
752    }
753
754    let (themes_text, themes_count) =
755        format_resource_diagnostics("Themes", resources.theme_diagnostics());
756    if themes_count > 0 {
757        resource_sections.push(themes_text);
758        count = count.saturating_add(themes_count);
759    }
760
761    if !resource_sections.is_empty() {
762        sections.push(format!(
763            "Resource diagnostics:\n{}",
764            resource_sections.join("\n")
765        ));
766    }
767
768    if sections.is_empty() {
769        (None, 0)
770    } else {
771        (
772            Some(format!("Reload diagnostics:\n\n{}", sections.join("\n\n"))),
773            count,
774        )
775    }
776}
777
778impl PiApp {
779    pub(super) fn sync_active_provider_credentials(&mut self, changed_provider: &str) {
780        let changed_canonical = normalize_auth_provider_input(changed_provider);
781        let auth = match crate::auth::AuthStorage::load(crate::config::Config::auth_path()) {
782            Ok(auth) => auth,
783            Err(err) => {
784                tracing::warn!(
785                    event = "pi.auth.sync_credentials.load_failed",
786                    provider = %changed_canonical,
787                    error = %err,
788                    "Skipping in-memory credential sync because auth storage could not be loaded"
789                );
790                return;
791            }
792        };
793
794        let provider_matches_changed =
795            |provider: &str| normalize_auth_provider_input(provider) == changed_canonical;
796
797        if !provider_matches_changed(&self.model_entry.model.provider) {
798            return;
799        }
800
801        // Keep catalog/model-scope entries immutable here so inline model keys
802        // are never overwritten by transient auth state. We only refresh the
803        // active runtime key.
804        let fallback_inline_key = self
805            .available_models
806            .iter()
807            .find(|entry| model_entry_matches(entry, &self.model_entry))
808            .and_then(|entry| normalize_api_key_opt(entry.api_key.clone()))
809            .or_else(|| normalize_api_key_opt(self.model_entry.api_key.clone()));
810
811        let resolved_key_opt =
812            normalize_api_key_opt(auth.resolve_api_key(&changed_canonical, None))
813                .or(fallback_inline_key);
814
815        if let Ok(mut agent_guard) = self.agent.try_lock() {
816            agent_guard
817                .stream_options_mut()
818                .api_key
819                .clone_from(&resolved_key_opt);
820        }
821
822        self.model_entry.api_key.clone_from(&resolved_key_opt);
823        if let Ok(mut shared_entry) = self.model_entry_shared.lock() {
824            shared_entry.api_key.clone_from(&resolved_key_opt);
825        }
826    }
827
828    pub(super) fn switch_active_model(
829        &mut self,
830        next: &ModelEntry,
831        provider_impl: std::sync::Arc<dyn crate::provider::Provider>,
832        resolved_key_opt: Option<&str>,
833        source: &str,
834    ) -> Result<(), String> {
835        let previous_entry = self.model_entry.clone();
836        let Ok(mut agent_guard) = self.agent.try_lock() else {
837            return Err("Agent busy; try again".to_string());
838        };
839        let Ok(mut session_guard) = self.session.try_lock() else {
840            return Err("Session busy; try again".to_string());
841        };
842        let resolved_key_opt = resolved_key_opt.map(str::to_string);
843
844        let current_thinking = agent_guard
845            .stream_options()
846            .thinking_level
847            .unwrap_or_default();
848        let next_thinking = next.clamp_thinking_level(current_thinking);
849        let previous_thinking = session_thinking_level(&session_guard);
850
851        agent_guard.set_provider(provider_impl);
852        let stream_options = agent_guard.stream_options_mut();
853        stream_options.api_key.clone_from(&resolved_key_opt);
854        stream_options.headers.clone_from(&next.headers);
855        stream_options.thinking_level = Some(next_thinking);
856
857        session_guard.header.provider = Some(next.model.provider.clone());
858        session_guard.header.model_id = Some(next.model.id.clone());
859        session_guard.append_model_change(next.model.provider.clone(), next.model.id.clone());
860        session_guard.header.thinking_level = Some(next_thinking.to_string());
861        if previous_thinking != Some(next_thinking) {
862            session_guard.append_thinking_level_change(next_thinking.to_string());
863        }
864
865        drop(session_guard);
866        drop(agent_guard);
867        self.spawn_save_session();
868
869        self.model_entry = next.clone();
870        if let Ok(mut guard) = self.model_entry_shared.lock() {
871            *guard = next.clone();
872        }
873        self.model = format!("{}/{}", next.model.provider, next.model.id);
874        self.dispatch_model_select_event(next, Some(&previous_entry), source);
875        Ok(())
876    }
877
878    fn dispatch_model_select_event(
879        &self,
880        next: &ModelEntry,
881        previous: Option<&ModelEntry>,
882        source: &str,
883    ) {
884        let Some(manager) = self.extensions.clone() else {
885            return;
886        };
887        let runtime_handle = self.runtime_handle.clone();
888        let source = match source {
889            "selector" | "command" => "set",
890            other => other,
891        };
892        let payload = json!({
893            "model": model_entry_event_payload(next),
894            "previousModel": previous.map(model_entry_event_payload),
895            "source": source,
896        });
897
898        runtime_handle.spawn(async move {
899            let _ = manager
900                .dispatch_event(ExtensionEventName::ModelSelect, Some(payload))
901                .await;
902        });
903    }
904
905    pub(super) fn sync_runtime_selection_from_session_header(&mut self) -> Result<(), String> {
906        let previous_entry = self.model_entry.clone();
907        let Ok(mut agent_guard) = self.agent.try_lock() else {
908            return Err("Agent busy; try again".to_string());
909        };
910        let Ok(mut session_guard) = self.session.try_lock() else {
911            return Err("Session busy; try again".to_string());
912        };
913
914        let session_model = session_guard.effective_model_for_current_path();
915        let session_thinking = session_guard.effective_thinking_level_for_current_path();
916
917        let (target_entry, sync_model) = match session_model.as_ref() {
918            Some((provider, model_id)) => {
919                if provider_ids_match(&self.model_entry.model.provider, provider)
920                    && self.model_entry.model.id.eq_ignore_ascii_case(model_id)
921                {
922                    (self.model_entry.clone(), true)
923                } else {
924                    (
925                        self.available_models
926                            .iter()
927                            .find(|entry| {
928                                provider_ids_match(&entry.model.provider, provider)
929                                    && entry.model.id.eq_ignore_ascii_case(model_id)
930                            })
931                            .cloned()
932                            .ok_or_else(|| {
933                                format!("Unable to switch provider/model to {provider}/{model_id}")
934                            })?,
935                        true,
936                    )
937                }
938            }
939            None => (self.model_entry.clone(), false),
940        };
941
942        let current_thinking = agent_guard
943            .stream_options()
944            .thinking_level
945            .unwrap_or_default();
946        let thinking_sync = plan_session_thinking_sync(
947            session_thinking.as_deref(),
948            current_thinking,
949            &target_entry,
950        );
951
952        let provider = agent_guard.provider();
953        let runtime_matches_target =
954            provider_ids_match(provider.name(), &target_entry.model.provider)
955                && provider
956                    .model_id()
957                    .eq_ignore_ascii_case(&target_entry.model.id);
958        if !runtime_matches_target {
959            let resolved_key_opt = target_entry
960                .api_key
961                .clone()
962                .or_else(|| resolve_model_key_from_default_auth(&target_entry));
963            if model_requires_configured_credential(&target_entry) && resolved_key_opt.is_none() {
964                return Err(format!(
965                    "Missing credentials for provider {}. Run /login {}.",
966                    target_entry.model.provider, target_entry.model.provider
967                ));
968            }
969
970            let provider_impl = providers::create_provider(&target_entry, self.extensions.as_ref())
971                .map_err(|err| err.to_string())?;
972            agent_guard.set_provider(provider_impl);
973            let stream_options = agent_guard.stream_options_mut();
974            stream_options.api_key.clone_from(&resolved_key_opt);
975            stream_options.headers.clone_from(&target_entry.headers);
976        }
977        agent_guard.stream_options_mut().thinking_level = Some(thinking_sync.effective);
978        drop(agent_guard);
979
980        let persist_needed = if thinking_sync.persist_needed {
981            let previous_thinking = session_thinking_level(&session_guard);
982            session_guard.header.thinking_level = Some(thinking_sync.effective.to_string());
983            if thinking_sync.thinking_changed && previous_thinking != Some(thinking_sync.effective)
984            {
985                session_guard.append_thinking_level_change(thinking_sync.effective.to_string());
986            }
987            true
988        } else {
989            false
990        };
991        drop(session_guard);
992
993        let model_changed = if sync_model && !model_entry_matches(&self.model_entry, &target_entry)
994        {
995            self.model_entry = target_entry.clone();
996            if let Ok(mut guard) = self.model_entry_shared.lock() {
997                *guard = target_entry.clone();
998            }
999            self.model = format!("{}/{}", target_entry.model.provider, target_entry.model.id);
1000            true
1001        } else {
1002            false
1003        };
1004
1005        if persist_needed {
1006            self.spawn_save_session();
1007        }
1008
1009        if model_changed {
1010            self.dispatch_model_select_event(&target_entry, Some(&previous_entry), "restore");
1011        }
1012
1013        Ok(())
1014    }
1015
1016    #[allow(clippy::too_many_lines)]
1017    pub(super) fn submit_oauth_code(
1018        &mut self,
1019        code_input: &str,
1020        pending: PendingOAuth,
1021    ) -> Option<Cmd> {
1022        // Do not store OAuth codes in history or session.
1023        self.input.reset();
1024        self.input_mode = InputMode::SingleLine;
1025        self.set_input_height(3);
1026
1027        self.agent_state = AgentState::Processing;
1028        self.scroll_to_bottom();
1029
1030        let event_tx = self.event_tx.clone();
1031        let PendingOAuth {
1032            provider,
1033            kind,
1034            verifier,
1035            oauth_config,
1036            device_code,
1037            redirect_uri,
1038        } = pending;
1039        let code_input = code_input.to_string();
1040
1041        let runtime_handle = self.runtime_handle.clone();
1042        let task_cx = Cx::current().unwrap_or_else(Cx::for_request);
1043        runtime_handle.spawn(async move {
1044            let auth_path = crate::config::Config::auth_path();
1045            let mut auth = match crate::auth::AuthStorage::load_async(auth_path).await {
1046                Ok(a) => a,
1047                Err(e) => {
1048                    let _ = crate::interactive::enqueue_pi_event(
1049                        &event_tx,
1050                        &task_cx,
1051                        PiMsg::AgentError(e.to_string()),
1052                    )
1053                    .await;
1054                    return;
1055                }
1056            };
1057
1058            let credential = match kind {
1059                PendingLoginKind::ApiKey => normalize_api_key_input(&code_input)
1060                    .map(|key| crate::auth::AuthCredential::ApiKey { key })
1061                    .map_err(crate::error::Error::auth),
1062                PendingLoginKind::OAuth => {
1063                    if provider == "anthropic" {
1064                        Box::pin(crate::auth::complete_anthropic_oauth(
1065                            &code_input,
1066                            &verifier,
1067                        ))
1068                        .await
1069                    } else if provider == "openai-codex" {
1070                        Box::pin(crate::auth::complete_openai_codex_oauth(
1071                            &code_input,
1072                            &verifier,
1073                        ))
1074                        .await
1075                    } else if provider == "google-gemini-cli" {
1076                        Box::pin(crate::auth::complete_google_gemini_cli_oauth(
1077                            &code_input,
1078                            &verifier,
1079                        ))
1080                        .await
1081                    } else if provider == "google-antigravity" {
1082                        Box::pin(crate::auth::complete_google_antigravity_oauth(
1083                            &code_input,
1084                            &verifier,
1085                        ))
1086                        .await
1087                    } else if provider == "github-copilot" || provider == "copilot" {
1088                        let client_id =
1089                            std::env::var("GITHUB_COPILOT_CLIENT_ID").unwrap_or_default();
1090                        let copilot_config = crate::auth::CopilotOAuthConfig {
1091                            client_id,
1092                            ..crate::auth::CopilotOAuthConfig::default()
1093                        };
1094                        Box::pin(crate::auth::complete_copilot_browser_oauth(
1095                            &copilot_config,
1096                            &code_input,
1097                            &verifier,
1098                            redirect_uri.as_deref(),
1099                        ))
1100                        .await
1101                    } else if provider == "gitlab" || provider == "gitlab-duo" {
1102                        let client_id = std::env::var("GITLAB_CLIENT_ID").unwrap_or_default();
1103                        let base_url = std::env::var("GITLAB_BASE_URL")
1104                            .unwrap_or_else(|_| "https://gitlab.com".to_string());
1105                        let gitlab_config = crate::auth::GitLabOAuthConfig {
1106                            client_id,
1107                            base_url,
1108                            ..crate::auth::GitLabOAuthConfig::default()
1109                        };
1110                        let gitlab_redirect_uri = redirect_uri
1111                            .clone()
1112                            .or_else(|| oauth_config.as_ref().and_then(|c| c.redirect_uri.clone()));
1113                        Box::pin(crate::auth::complete_gitlab_oauth(
1114                            &gitlab_config,
1115                            &code_input,
1116                            &verifier,
1117                            gitlab_redirect_uri.as_deref(),
1118                        ))
1119                        .await
1120                    } else if let Some(config) = &oauth_config {
1121                        Box::pin(crate::auth::complete_extension_oauth(
1122                            config,
1123                            &code_input,
1124                            &verifier,
1125                        ))
1126                        .await
1127                    } else {
1128                        Err(crate::error::Error::auth(format!(
1129                            "OAuth provider not supported: {provider}"
1130                        )))
1131                    }
1132                }
1133                PendingLoginKind::DeviceFlow => match device_code {
1134                    Some(dc) => {
1135                        let poll_result = if provider == "kimi-for-coding" {
1136                            Box::pin(crate::auth::poll_kimi_code_device_flow(&dc)).await
1137                        } else {
1138                            let client_id =
1139                                std::env::var("GITHUB_COPILOT_CLIENT_ID").unwrap_or_default();
1140                            let copilot_config = crate::auth::CopilotOAuthConfig {
1141                                client_id,
1142                                ..crate::auth::CopilotOAuthConfig::default()
1143                            };
1144                            Box::pin(crate::auth::poll_copilot_device_flow(&copilot_config, &dc))
1145                                .await
1146                        };
1147                        match poll_result {
1148                            crate::auth::DeviceFlowPollResult::Success(cred) => Ok(cred),
1149                            crate::auth::DeviceFlowPollResult::Error(e) => {
1150                                Err(crate::error::Error::auth(e))
1151                            }
1152                            crate::auth::DeviceFlowPollResult::Expired => {
1153                                Err(crate::error::Error::auth(format!(
1154                                    "Device code expired for {provider}. Run /login {provider} again."
1155                                )))
1156                            }
1157                            crate::auth::DeviceFlowPollResult::AccessDenied => {
1158                                Err(crate::error::Error::auth(format!(
1159                                    "Access denied for {provider}."
1160                                )))
1161                            }
1162                            crate::auth::DeviceFlowPollResult::Pending => {
1163                                Err(crate::error::Error::auth(format!(
1164                                    "Authorization for {provider} is still pending. Complete the browser step and submit again."
1165                                )))
1166                            }
1167                            crate::auth::DeviceFlowPollResult::SlowDown => {
1168                                Err(crate::error::Error::auth(format!(
1169                                    "Authorization server asked to slow down for {provider}. Wait a few seconds and submit again."
1170                                )))
1171                            }
1172                        }
1173                    }
1174                    None => Err(crate::error::Error::auth(
1175                        "Device flow missing device_code".to_string(),
1176                    )),
1177                },
1178            };
1179
1180            let credential = match credential {
1181                Ok(c) => c,
1182                Err(e) => {
1183                    let _ = crate::interactive::enqueue_pi_event(
1184                        &event_tx,
1185                        &task_cx,
1186                        PiMsg::AgentError(e.to_string()),
1187                    )
1188                    .await;
1189                    return;
1190                }
1191            };
1192
1193            save_provider_credential(&mut auth, &provider, credential);
1194            if let Err(e) = auth.save_async().await {
1195                let _ = crate::interactive::enqueue_pi_event(
1196                    &event_tx,
1197                    &task_cx,
1198                    PiMsg::AgentError(e.to_string()),
1199                )
1200                .await;
1201                return;
1202            }
1203            let _ = crate::interactive::enqueue_pi_event(
1204                &event_tx,
1205                &task_cx,
1206                PiMsg::CredentialUpdated {
1207                    provider: provider.clone(),
1208                },
1209            )
1210            .await;
1211
1212            let status = match kind {
1213                PendingLoginKind::ApiKey => {
1214                    format!("API key saved for {provider}. Credentials saved to auth.json.")
1215                }
1216                PendingLoginKind::OAuth | PendingLoginKind::DeviceFlow => {
1217                    format!(
1218                        "OAuth login successful for {provider}. Credentials saved to auth.json."
1219                    )
1220                }
1221            };
1222            let _ = crate::interactive::enqueue_pi_event(
1223                &event_tx,
1224                &task_cx,
1225                PiMsg::System(status),
1226            )
1227            .await;
1228        });
1229
1230        None
1231    }
1232
1233    #[allow(clippy::too_many_lines)]
1234    pub(super) fn submit_bash_command(
1235        &mut self,
1236        raw_message: &str,
1237        command: String,
1238        exclude_from_context: bool,
1239    ) -> Option<Cmd> {
1240        if self.bash_running {
1241            self.status_message = Some("A bash command is already running.".to_string());
1242            return None;
1243        }
1244
1245        self.bash_running = true;
1246        self.agent_state = AgentState::ToolRunning;
1247        self.current_tool = Some("bash".to_string());
1248        self.history.push(raw_message.to_string());
1249
1250        self.input.reset();
1251        self.input_mode = InputMode::SingleLine;
1252        self.set_input_height(3);
1253
1254        let event_tx = self.event_tx.clone();
1255        let session = Arc::clone(&self.session);
1256        let save_enabled = self.save_enabled;
1257        let cwd = self.cwd.clone();
1258        let cwd_display = cwd.display().to_string();
1259        let shell_path = self.config.shell_path.clone();
1260        let command_prefix = self.config.shell_command_prefix.clone();
1261        let extensions = self.extensions.clone();
1262        let runtime_handle = self.runtime_handle.clone();
1263        let task_cx = Cx::current().unwrap_or_else(Cx::for_request);
1264
1265        runtime_handle.spawn(async move {
1266            let mut override_result = None;
1267            if let Some(manager) = extensions {
1268                let response = manager
1269                    .dispatch_event_with_response(
1270                        ExtensionEventName::UserBash,
1271                        Some(json!({
1272                            "command": command.clone(),
1273                            "excludeFromContext": exclude_from_context,
1274                            "cwd": cwd_display,
1275                        })),
1276                        EXTENSION_EVENT_TIMEOUT_MS,
1277                    )
1278                    .await
1279                    .unwrap_or(None);
1280                if let Some(value) = response {
1281                    override_result = parse_user_bash_event_result(&value);
1282                }
1283            }
1284
1285            let result = match override_result {
1286                Some(result) => Ok(result),
1287                None => {
1288                    crate::tools::run_bash_command(
1289                        &cwd,
1290                        shell_path.as_deref(),
1291                        command_prefix.as_deref(),
1292                        &command,
1293                        None,
1294                        None,
1295                    )
1296                    .await
1297                }
1298            };
1299
1300            match result {
1301                Ok(result) => {
1302                    let display = bash_execution_to_text(
1303                        &command,
1304                        &result.output,
1305                        result.exit_code,
1306                        result.cancelled,
1307                        result.truncated,
1308                        result.full_output_path.as_deref(),
1309                    );
1310
1311                    if exclude_from_context {
1312                        let mut extra = HashMap::new();
1313                        extra.insert("excludeFromContext".to_string(), Value::Bool(true));
1314
1315                        let bash_message = SessionMessage::BashExecution {
1316                            command: command.clone(),
1317                            output: result.output.clone(),
1318                            exit_code: result.exit_code,
1319                            cancelled: Some(result.cancelled),
1320                            truncated: Some(result.truncated),
1321                            full_output_path: result.full_output_path.clone(),
1322                            timestamp: Some(Utc::now().timestamp_millis()),
1323                            extra,
1324                        };
1325
1326                        if let Ok(mut session_guard) = session.lock(&task_cx).await {
1327                            session_guard.append_message(bash_message);
1328                            if save_enabled {
1329                                let _ = session_guard.save().await;
1330                            }
1331                        }
1332
1333                        let mut display = display;
1334                        display.push_str("\n\n[Output excluded from model context]");
1335                        let _ = crate::interactive::enqueue_pi_event(
1336                            &event_tx,
1337                            &task_cx,
1338                            PiMsg::BashResult {
1339                                display,
1340                                content_for_agent: None,
1341                            },
1342                        )
1343                        .await;
1344                    } else {
1345                        let content_for_agent =
1346                            vec![ContentBlock::Text(TextContent::new(display.clone()))];
1347                        let _ = crate::interactive::enqueue_pi_event(
1348                            &event_tx,
1349                            &task_cx,
1350                            PiMsg::BashResult {
1351                                display,
1352                                content_for_agent: Some(content_for_agent),
1353                            },
1354                        )
1355                        .await;
1356                    }
1357                }
1358                Err(err) => {
1359                    let _ = crate::interactive::enqueue_pi_event(
1360                        &event_tx,
1361                        &task_cx,
1362                        PiMsg::BashResult {
1363                            display: format!("Bash command failed: {err}"),
1364                            content_for_agent: None,
1365                        },
1366                    )
1367                    .await;
1368                }
1369            }
1370        });
1371
1372        None
1373    }
1374
1375    pub(super) fn format_themes_list(&self) -> String {
1376        let mut names = Vec::new();
1377        names.push("dark".to_string());
1378        names.push("light".to_string());
1379        names.push("solarized".to_string());
1380
1381        for path in Theme::discover_themes(&self.cwd) {
1382            if let Ok(theme) = Theme::load(&path) {
1383                names.push(theme.name);
1384            }
1385        }
1386
1387        names.sort_by_key(|a| a.to_ascii_lowercase());
1388        names.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
1389
1390        let mut output = String::from("Available themes:\n");
1391        for name in names {
1392            let marker = if name.eq_ignore_ascii_case(&self.theme.name) {
1393                "* "
1394            } else {
1395                "  "
1396            };
1397            let _ = writeln!(output, "{marker}{name}");
1398        }
1399        output.push_str("\nUse /theme <name> to switch");
1400        output
1401    }
1402
1403    pub(super) fn format_scoped_models_status(&self) -> String {
1404        let patterns = self.config.enabled_models.as_deref().unwrap_or(&[]);
1405        let scope_configured = !patterns.is_empty();
1406
1407        let mut output = String::new();
1408        let current = format!(
1409            "{}/{}",
1410            self.model_entry.model.provider, self.model_entry.model.id
1411        );
1412        let _ = writeln!(output, "Current model: {current}");
1413        let _ = writeln!(output);
1414
1415        if !scope_configured {
1416            let _ = writeln!(output, "Scoped models: (all models)");
1417            let _ = writeln!(output);
1418            output.push_str("Use /scoped-models <patterns> to scope Ctrl+P cycling.\n");
1419            output.push_str("Use /scoped-models clear to clear scope.\n");
1420            return output;
1421        }
1422
1423        output.push_str("Scoped model patterns:\n");
1424        for pattern in patterns {
1425            let _ = writeln!(output, "  - {pattern}");
1426        }
1427        let _ = writeln!(output);
1428
1429        output.push_str("Scoped models (matched):\n");
1430        if self.model_scope.is_empty() {
1431            output.push_str("  (none)\n");
1432        } else {
1433            let mut models = self
1434                .model_scope
1435                .iter()
1436                .map(|entry| format!("{}/{}", entry.model.provider, entry.model.id))
1437                .collect::<Vec<_>>();
1438            models.sort_by_key(|value| value.to_ascii_lowercase());
1439            models.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
1440            for model in models {
1441                let _ = writeln!(output, "  - {model}");
1442            }
1443        }
1444        let _ = writeln!(output);
1445
1446        output.push_str("Use /scoped-models clear to cycle all models.\n");
1447        output
1448    }
1449
1450    pub(super) fn format_input_history(&self) -> String {
1451        let entries = self.history.entries();
1452        if entries.is_empty() {
1453            return "No input history yet.".to_string();
1454        }
1455
1456        let mut output = String::from("Input history (most recent first):\n");
1457        for (idx, entry) in entries.iter().rev().take(50).enumerate() {
1458            let trimmed = entry.value.trim();
1459            if trimmed.is_empty() {
1460                continue;
1461            }
1462            let preview = trimmed.replace('\n', "\\n");
1463            let preview = preview.chars().take(120).collect::<String>();
1464            let _ = writeln!(output, "  {}. {preview}", idx + 1);
1465        }
1466        output
1467    }
1468
1469    pub(super) fn format_session_info(&self, session: &Session) -> String {
1470        let file = session.path.as_ref().map_or_else(
1471            || "(not saved yet)".to_string(),
1472            |p| p.display().to_string(),
1473        );
1474        let name = session.get_name().unwrap_or_else(|| "-".to_string());
1475        let thinking = session
1476            .header
1477            .thinking_level
1478            .as_deref()
1479            .unwrap_or("off")
1480            .to_string();
1481
1482        let message_count = session
1483            .entries_for_current_path()
1484            .iter()
1485            .filter(|entry| matches!(entry, SessionEntry::Message(_)))
1486            .count();
1487
1488        let total_tokens = self.total_usage.total_tokens;
1489        let total_cost = self.total_usage.cost.total;
1490        let cost_str = if total_cost > 0.0 {
1491            format!("${total_cost:.4}")
1492        } else {
1493            "$0.0000".to_string()
1494        };
1495
1496        let mut info = format!(
1497            "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}",
1498            id = session.header.id,
1499            model = self.model,
1500        );
1501        info.push_str("\n\n");
1502        info.push_str(&self.frame_timing.summary());
1503        info.push_str("\n\n");
1504        info.push_str(&self.memory_monitor.summary());
1505        info
1506    }
1507
1508    /// Handle a slash command.
1509    #[allow(clippy::too_many_lines)]
1510    pub(super) fn handle_slash_command(&mut self, cmd: SlashCommand, args: &str) -> Option<Cmd> {
1511        // Clear input
1512        self.input.reset();
1513
1514        match cmd {
1515            SlashCommand::Help => {
1516                self.messages.push(ConversationMessage {
1517                    role: MessageRole::System,
1518                    content: SlashCommand::help_text().to_string(),
1519                    thinking: None,
1520                    collapsed: false,
1521                });
1522                self.scroll_to_last_match("Available commands:");
1523                None
1524            }
1525            SlashCommand::Login => self.handle_slash_login(args),
1526            SlashCommand::Logout => self.handle_slash_logout(args),
1527            SlashCommand::Clear => {
1528                self.messages.clear();
1529                self.current_response.clear();
1530                self.current_thinking.clear();
1531                self.current_tool = None;
1532                self.pending_tool_output = None;
1533                self.abort_handle = None;
1534                self.autocomplete.close();
1535                self.message_render_cache.clear();
1536                self.status_message = Some("Conversation cleared".to_string());
1537                self.scroll_to_bottom();
1538                None
1539            }
1540            SlashCommand::Model => self.handle_slash_model(args),
1541            SlashCommand::Thinking => self.handle_slash_thinking(args),
1542            SlashCommand::ScopedModels => self.handle_slash_scoped_models(args),
1543            SlashCommand::Exit => Some(self.quit_cmd()),
1544            SlashCommand::History => {
1545                self.messages.push(ConversationMessage {
1546                    role: MessageRole::System,
1547                    content: self.format_input_history(),
1548                    thinking: None,
1549                    collapsed: false,
1550                });
1551                self.scroll_to_last_match("Input history");
1552                None
1553            }
1554            SlashCommand::Export => {
1555                if self.agent_state != AgentState::Idle {
1556                    self.status_message = Some("Cannot export while processing".to_string());
1557                    return None;
1558                }
1559
1560                let (output_path, html) = {
1561                    let Ok(session_guard) = self.session.try_lock() else {
1562                        self.status_message = Some("Session busy; try again".to_string());
1563                        return None;
1564                    };
1565                    let output_path = if args.trim().is_empty() {
1566                        self.default_export_path(&session_guard)
1567                    } else {
1568                        self.resolve_output_path(args)
1569                    };
1570                    let html = session_guard.to_html();
1571                    (output_path, html)
1572                };
1573
1574                if let Some(parent) = output_path.parent() {
1575                    if !parent.as_os_str().is_empty() {
1576                        if let Err(err) = std::fs::create_dir_all(parent) {
1577                            self.status_message = Some(format!("Failed to create dir: {err}"));
1578                            return None;
1579                        }
1580                    }
1581                }
1582                if let Err(err) = std::fs::write(&output_path, html) {
1583                    self.status_message = Some(format!("Failed to write export: {err}"));
1584                    return None;
1585                }
1586
1587                self.messages.push(ConversationMessage {
1588                    role: MessageRole::System,
1589                    content: format!("Exported HTML: {}", output_path.display()),
1590                    thinking: None,
1591                    collapsed: false,
1592                });
1593                self.scroll_to_bottom();
1594                self.status_message = Some(format!("Exported: {}", output_path.display()));
1595                None
1596            }
1597            SlashCommand::Session => {
1598                let Ok(session_guard) = self.session.try_lock() else {
1599                    self.status_message = Some("Session busy; try again".to_string());
1600                    return None;
1601                };
1602                let info = self.format_session_info(&session_guard);
1603                drop(session_guard);
1604                self.messages.push(ConversationMessage {
1605                    role: MessageRole::System,
1606                    content: info,
1607                    thinking: None,
1608                    collapsed: false,
1609                });
1610                self.scroll_to_bottom();
1611                None
1612            }
1613            SlashCommand::Settings => {
1614                if self.agent_state != AgentState::Idle {
1615                    self.status_message = Some("Cannot open settings while processing".to_string());
1616                    return None;
1617                }
1618
1619                let mut settings = SettingsUiState::new();
1620                settings.max_visible = super::overlay_max_visible(self.term_height);
1621                self.settings_ui = Some(settings);
1622                self.session_picker = None;
1623                self.autocomplete.close();
1624                None
1625            }
1626            SlashCommand::Theme => {
1627                let name = args.trim();
1628                if name.is_empty() {
1629                    self.messages.push(ConversationMessage {
1630                        role: MessageRole::System,
1631                        content: self.format_themes_list(),
1632                        thinking: None,
1633                        collapsed: false,
1634                    });
1635                    self.scroll_to_last_match("Available themes:");
1636                    return None;
1637                }
1638
1639                let theme = if name.eq_ignore_ascii_case("dark") {
1640                    Theme::dark()
1641                } else if name.eq_ignore_ascii_case("light") {
1642                    Theme::light()
1643                } else if name.eq_ignore_ascii_case("solarized") {
1644                    Theme::solarized()
1645                } else {
1646                    match Theme::load_by_name(name, &self.cwd) {
1647                        Ok(theme) => theme,
1648                        Err(err) => {
1649                            self.status_message = Some(err.to_string());
1650                            return None;
1651                        }
1652                    }
1653                };
1654
1655                let theme_name = theme.name.clone();
1656                self.apply_theme(theme);
1657                self.config.theme = Some(theme_name.clone());
1658
1659                if let Err(err) = self.persist_project_theme(&theme_name) {
1660                    tracing::warn!("Failed to persist theme preference: {err}");
1661                    self.status_message = Some(format!(
1662                        "Switched to theme: {theme_name} (not saved: {err})"
1663                    ));
1664                } else {
1665                    self.status_message = Some(format!("Switched to theme: {theme_name}"));
1666                }
1667
1668                None
1669            }
1670            SlashCommand::Resume => {
1671                if self.agent_state != AgentState::Idle {
1672                    self.status_message = Some("Cannot resume while processing".to_string());
1673                    return None;
1674                }
1675
1676                let override_dir = self
1677                    .session
1678                    .try_lock()
1679                    .ok()
1680                    .and_then(|guard| guard.session_dir.clone());
1681                let base_dir = override_dir.clone().unwrap_or_else(Config::sessions_dir);
1682                let sessions = crate::session_picker::list_sessions_for_project(
1683                    &self.cwd,
1684                    override_dir.as_deref(),
1685                );
1686                if sessions.is_empty() {
1687                    self.status_message = Some("No sessions found for this project".to_string());
1688                    return None;
1689                }
1690
1691                let mut picker = SessionPickerOverlay::new_with_root(sessions, Some(base_dir));
1692                picker.max_visible = super::overlay_max_visible(self.term_height);
1693                self.session_picker = Some(picker);
1694                self.autocomplete.close();
1695                None
1696            }
1697            SlashCommand::New => {
1698                if self.agent_state != AgentState::Idle {
1699                    self.status_message =
1700                        Some("Cannot start a new session while processing".to_string());
1701                    return None;
1702                }
1703
1704                let Some(extensions) = self.extensions.clone() else {
1705                    let Ok(mut session_guard) = self.session.try_lock() else {
1706                        self.status_message = Some("Session busy; try again".to_string());
1707                        return None;
1708                    };
1709                    let session_dir = session_guard.session_dir.clone();
1710                    *session_guard = Session::create_with_dir(session_dir);
1711                    session_guard.header.provider = Some(self.model_entry.model.provider.clone());
1712                    session_guard.header.model_id = Some(self.model_entry.model.id.clone());
1713                    session_guard.header.thinking_level = Some(ThinkingLevel::Off.to_string());
1714                    drop(session_guard);
1715
1716                    if let Ok(mut agent_guard) = self.agent.try_lock() {
1717                        agent_guard.replace_messages(Vec::new());
1718                        agent_guard.stream_options_mut().thinking_level = Some(ThinkingLevel::Off);
1719                    }
1720
1721                    self.messages.clear();
1722                    self.message_render_cache.clear();
1723                    self.total_usage = Usage::default();
1724                    self.current_response.clear();
1725                    self.current_thinking.clear();
1726                    self.current_tool = None;
1727                    self.pending_tool_output = None;
1728                    self.abort_handle = None;
1729                    self.pending_oauth = None;
1730                    self.session_picker = None;
1731                    self.tree_ui = None;
1732                    self.autocomplete.close();
1733                    self.message_render_cache.clear();
1734
1735                    self.status_message = Some(format!(
1736                        "Started new session\nModel set to {}\nThinking level: off",
1737                        self.model
1738                    ));
1739                    self.scroll_to_bottom();
1740                    self.input.focus();
1741                    return None;
1742                };
1743
1744                let model_provider = self.model_entry.model.provider.clone();
1745                let model_id = self.model_entry.model.id.clone();
1746                let model_label = self.model.clone();
1747                let event_tx = self.event_tx.clone();
1748                let session = Arc::clone(&self.session);
1749                let agent = Arc::clone(&self.agent);
1750                let runtime_handle = self.runtime_handle.clone();
1751
1752                let previous_session_file = self
1753                    .session
1754                    .try_lock()
1755                    .ok()
1756                    .and_then(|guard| guard.path.as_ref().map(|p| p.display().to_string()));
1757
1758                self.agent_state = AgentState::Processing;
1759                self.status_message = Some("Starting new session...".to_string());
1760
1761                let task_cx = Cx::current().unwrap_or_else(Cx::for_request);
1762                runtime_handle.spawn(async move {
1763                    let cancelled = extensions
1764                        .dispatch_cancellable_event(
1765                            ExtensionEventName::SessionBeforeSwitch,
1766                            Some(json!({ "reason": "new" })),
1767                            EXTENSION_EVENT_TIMEOUT_MS,
1768                        )
1769                        .await
1770                        .unwrap_or(false);
1771                    if cancelled {
1772                        let _ = crate::interactive::enqueue_pi_event(
1773                            &event_tx,
1774                            &task_cx,
1775                            PiMsg::System("Session switch cancelled by extension".to_string()),
1776                        )
1777                        .await;
1778                        return;
1779                    }
1780
1781                    let new_session_id = {
1782                        let mut guard = match session.lock(&task_cx).await {
1783                            Ok(guard) => guard,
1784                            Err(err) => {
1785                                let _ = crate::interactive::enqueue_pi_event(
1786                                    &event_tx,
1787                                    &asupersync::Cx::for_request(),
1788                                    PiMsg::AgentError(format!("Failed to lock session: {err}")),
1789                                )
1790                                .await;
1791                                return;
1792                            }
1793                        };
1794                        let session_dir = guard.session_dir.clone();
1795                        let mut new_session = Session::create_with_dir(session_dir);
1796                        new_session.header.provider = Some(model_provider);
1797                        new_session.header.model_id = Some(model_id);
1798                        new_session.header.thinking_level = Some(ThinkingLevel::Off.to_string());
1799                        let new_id = new_session.header.id.clone();
1800                        *guard = new_session;
1801                        new_id
1802                    };
1803
1804                    {
1805                        let mut agent_guard = match agent.lock(&task_cx).await {
1806                            Ok(guard) => guard,
1807                            Err(err) => {
1808                                let _ = crate::interactive::enqueue_pi_event(
1809                                    &event_tx,
1810                                    &task_cx,
1811                                    PiMsg::AgentError(format!("Failed to lock agent: {err}")),
1812                                )
1813                                .await;
1814                                return;
1815                            }
1816                        };
1817                        agent_guard.replace_messages(Vec::new());
1818                        agent_guard.stream_options_mut().thinking_level = Some(ThinkingLevel::Off);
1819                    }
1820
1821                    let _ = crate::interactive::enqueue_pi_event(
1822                        &event_tx,
1823                        &task_cx,
1824                        PiMsg::ConversationReset {
1825                            messages: Vec::new(),
1826                            usage: Usage::default(),
1827                            status: Some(format!(
1828                                "Started new session\nModel set to {model_label}\nThinking level: off"
1829                            )),
1830                        },
1831                    )
1832                    .await;
1833
1834                    let _ = extensions
1835                        .dispatch_event(
1836                            ExtensionEventName::SessionSwitch,
1837                            Some(json!({
1838                                "reason": "new",
1839                                "previousSessionFile": previous_session_file,
1840                                "sessionId": new_session_id,
1841                            })),
1842                        )
1843                        .await;
1844                });
1845
1846                None
1847            }
1848            SlashCommand::Copy => {
1849                if self.agent_state != AgentState::Idle {
1850                    self.status_message = Some("Cannot copy while processing".to_string());
1851                    return None;
1852                }
1853
1854                let text = self
1855                    .messages
1856                    .iter()
1857                    .rev()
1858                    .find(|m| m.role == MessageRole::Assistant && !m.content.trim().is_empty())
1859                    .map(|m| m.content.clone());
1860
1861                let Some(text) = text else {
1862                    self.status_message = Some("No agent messages to copy yet.".to_string());
1863                    return None;
1864                };
1865
1866                let write_fallback = |text: &str| -> std::io::Result<std::path::PathBuf> {
1867                    use std::io::Write;
1868                    let dir = std::env::temp_dir();
1869                    let filename = format!("pi_copy_{}.txt", Utc::now().timestamp_millis());
1870                    let path = dir.join(filename);
1871
1872                    let mut options = std::fs::OpenOptions::new();
1873                    options.write(true).create_new(true);
1874                    #[cfg(unix)]
1875                    {
1876                        use std::os::unix::fs::OpenOptionsExt;
1877                        options.mode(0o600);
1878                    }
1879
1880                    let mut file = options.open(&path)?;
1881                    file.write_all(text.as_bytes())?;
1882
1883                    Ok(path)
1884                };
1885
1886                #[cfg(feature = "clipboard")]
1887                {
1888                    match ArboardClipboard::new()
1889                        .and_then(|mut clipboard| clipboard.set_text(text.clone()))
1890                    {
1891                        Ok(()) => self.status_message = Some("Copied to clipboard".to_string()),
1892                        Err(err) => match write_fallback(&text) {
1893                            Ok(path) => {
1894                                self.status_message = Some(format!(
1895                                    "Clipboard support is disabled or unavailable ({err}). Wrote to {}",
1896                                    path.display()
1897                                ));
1898                            }
1899                            Err(io_err) => {
1900                                self.status_message = Some(format!(
1901                                    "Clipboard support is disabled or unavailable ({err}); also failed to write fallback file: {io_err}"
1902                                ));
1903                            }
1904                        },
1905                    }
1906                }
1907
1908                #[cfg(not(feature = "clipboard"))]
1909                {
1910                    match write_fallback(&text) {
1911                        Ok(path) => {
1912                            self.status_message = Some(format!(
1913                                "Clipboard support is disabled. Wrote to {}",
1914                                path.display()
1915                            ));
1916                        }
1917                        Err(err) => {
1918                            self.status_message = Some(format!(
1919                                "Clipboard support is disabled; failed to write fallback file: {err}"
1920                            ));
1921                        }
1922                    }
1923                }
1924
1925                None
1926            }
1927            SlashCommand::Name => {
1928                let name = args.trim();
1929                if name.is_empty() {
1930                    self.status_message = Some("Usage: /name <name>".to_string());
1931                    return None;
1932                }
1933
1934                let Ok(mut session_guard) = self.session.try_lock() else {
1935                    self.status_message = Some("Session busy; try again".to_string());
1936                    return None;
1937                };
1938                session_guard.append_session_info(Some(name.to_string()));
1939                drop(session_guard);
1940                self.spawn_save_session();
1941
1942                self.status_message = Some(format!("Session name: {name}"));
1943                None
1944            }
1945            SlashCommand::Hotkeys => {
1946                self.messages.push(ConversationMessage {
1947                    role: MessageRole::System,
1948                    content: self.format_hotkeys(),
1949                    thinking: None,
1950                    collapsed: false,
1951                });
1952                self.scroll_to_bottom();
1953                None
1954            }
1955            SlashCommand::Changelog => {
1956                let content = include_str!("../../CHANGELOG.md").to_string();
1957                self.messages.push(ConversationMessage {
1958                    role: MessageRole::System,
1959                    content,
1960                    thinking: None,
1961                    collapsed: false,
1962                });
1963                self.scroll_to_last_match("# ");
1964                None
1965            }
1966            SlashCommand::Tree => {
1967                if self.agent_state != AgentState::Idle {
1968                    self.status_message = Some("Cannot open tree while processing".to_string());
1969                    return None;
1970                }
1971
1972                if let Some(extensions) = self.extensions.clone() {
1973                    let session = Arc::clone(&self.session);
1974                    let event_tx = self.event_tx.clone();
1975                    let runtime_handle = self.runtime_handle.clone();
1976                    let args = args.to_string();
1977                    let task_cx = Cx::current().unwrap_or_else(Cx::for_request);
1978
1979                    runtime_handle.spawn(async move {
1980                        let cx = Cx::current().unwrap_or_else(Cx::for_request);
1981                        let (initial_selected_id, branch_count, entry_count) =
1982                            match session.lock(&cx).await {
1983                                Ok(session_guard) => {
1984                                    let initial_selected_id =
1985                                        resolve_tree_selector_initial_id(&session_guard, &args);
1986                                    let branch_count = session_guard.list_leaves().len();
1987                                    let entry_count = session_guard.entries.len();
1988                                    (initial_selected_id, branch_count, entry_count)
1989                                }
1990                                Err(err) => {
1991                                    let _ = crate::interactive::enqueue_pi_event(
1992                                        &event_tx,
1993                                        &task_cx,
1994                                        PiMsg::AgentError(format!("Failed to lock session: {err}")),
1995                                    )
1996                                    .await;
1997                                    return;
1998                                }
1999                            };
2000
2001                        let response = extensions
2002                            .dispatch_event_with_response(
2003                                ExtensionEventName::SessionBeforeTree,
2004                                Some(json!({
2005                                    "preparation": {
2006                                        "branchCount": branch_count,
2007                                        "entryCount": entry_count,
2008                                    }
2009                                })),
2010                                EXTENSION_EVENT_TIMEOUT_MS,
2011                            )
2012                            .await
2013                            .unwrap_or(None);
2014
2015                        let mut label = None;
2016                        let mut cancelled = false;
2017                        if let Some(value) = response {
2018                            if value.as_bool() == Some(false) {
2019                                cancelled = true;
2020                            }
2021                            if let Some(obj) = value.as_object() {
2022                                if obj.get("cancel").and_then(Value::as_bool).unwrap_or(false)
2023                                    || obj
2024                                        .get("cancelled")
2025                                        .and_then(Value::as_bool)
2026                                        .unwrap_or(false)
2027                                {
2028                                    cancelled = true;
2029                                }
2030                                if let Some(custom_label) = obj.get("label").and_then(Value::as_str)
2031                                {
2032                                    label = Some(custom_label.to_string());
2033                                }
2034                            }
2035                        }
2036
2037                        if cancelled {
2038                            let _ = crate::interactive::enqueue_pi_event(
2039                                &event_tx,
2040                                &task_cx,
2041                                PiMsg::System("Session tree cancelled by extension".to_string()),
2042                            )
2043                            .await;
2044                            return;
2045                        }
2046
2047                        let _ = crate::interactive::enqueue_pi_event(
2048                            &event_tx,
2049                            &task_cx,
2050                            PiMsg::OpenTree {
2051                                initial_selected_id,
2052                                label,
2053                            },
2054                        )
2055                        .await;
2056                    });
2057
2058                    self.status_message = Some("Preparing tree...".to_string());
2059                    return None;
2060                }
2061
2062                let Ok(session_guard) = self.session.try_lock() else {
2063                    self.status_message = Some("Session busy; try again".to_string());
2064                    return None;
2065                };
2066                let initial_selected_id = resolve_tree_selector_initial_id(&session_guard, args);
2067                let selector = TreeSelectorState::new(
2068                    &session_guard,
2069                    self.term_height,
2070                    initial_selected_id.as_deref(),
2071                    None,
2072                );
2073                drop(session_guard);
2074                self.tree_ui = Some(TreeUiState::Selector(selector));
2075                None
2076            }
2077            SlashCommand::Fork => self.handle_slash_fork(args),
2078            SlashCommand::Compact => self.handle_slash_compact(args),
2079            SlashCommand::Reload => self.handle_slash_reload(),
2080            SlashCommand::Template => self.handle_slash_template(args),
2081            SlashCommand::Share => self.handle_slash_share(args),
2082        }
2083    }
2084
2085    #[allow(clippy::too_many_lines)]
2086    pub(super) fn handle_slash_login(&mut self, args: &str) -> Option<Cmd> {
2087        if self.agent_state != AgentState::Idle {
2088            self.status_message = Some("Cannot login while processing".to_string());
2089            return None;
2090        }
2091
2092        let args = args.trim();
2093        if args.is_empty() {
2094            let auth_path = crate::config::Config::auth_path();
2095            match crate::auth::AuthStorage::load(auth_path) {
2096                Ok(auth) => {
2097                    let listing = format_login_provider_listing(&auth, &self.available_models);
2098                    self.messages.push(ConversationMessage {
2099                        role: MessageRole::System,
2100                        content: listing,
2101                        thinking: None,
2102                        collapsed: false,
2103                    });
2104                    self.scroll_to_last_match("Available login providers:");
2105                }
2106                Err(err) => {
2107                    self.status_message = Some(format!("Unable to load auth status: {err}"));
2108                }
2109            }
2110            return None;
2111        }
2112
2113        let requested_provider = args.split_whitespace().next().unwrap_or(args).to_string();
2114        let provider = normalize_auth_provider_input(&requested_provider);
2115
2116        if provider == "kimi-for-coding" {
2117            self.status_message = Some("Starting Kimi Code login...".to_string());
2118            let event_tx = self.event_tx.clone();
2119            let provider_clone = provider;
2120            let runtime_handle = self.runtime_handle.clone();
2121            let cx = asupersync::Cx::current().unwrap_or_else(asupersync::Cx::for_request);
2122
2123            runtime_handle.spawn(async move {
2124                match crate::auth::start_kimi_code_device_flow().await {
2125                    Ok(device) => {
2126                        let _ = crate::interactive::enqueue_pi_event(
2127                            &event_tx,
2128                            &cx,
2129                            PiMsg::OAuthDeviceFlowStarted {
2130                                provider: provider_clone,
2131                                device_code: device.device_code,
2132                                user_code: device.user_code,
2133                                verification_uri: device
2134                                    .verification_uri_complete
2135                                    .unwrap_or(device.verification_uri),
2136                                expires_in: device.expires_in,
2137                            },
2138                        )
2139                        .await;
2140                    }
2141                    Err(err) => {
2142                        let _ = crate::interactive::enqueue_pi_event(
2143                            &event_tx,
2144                            &cx,
2145                            PiMsg::AgentError(format!("OAuth login failed: {err}")),
2146                        )
2147                        .await;
2148                    }
2149                }
2150            });
2151            return None;
2152        }
2153
2154        if let Some(prompt) = api_key_login_prompt(&provider) {
2155            self.messages.push(ConversationMessage {
2156                role: MessageRole::System,
2157                content: prompt,
2158                thinking: None,
2159                collapsed: false,
2160            });
2161            self.scroll_to_bottom();
2162            self.pending_oauth = Some(PendingOAuth {
2163                provider,
2164                kind: PendingLoginKind::ApiKey,
2165                verifier: String::new(),
2166                oauth_config: None,
2167                device_code: None,
2168                redirect_uri: None,
2169            });
2170            self.input_mode = InputMode::SingleLine;
2171            self.set_input_height(3);
2172            self.input.focus();
2173            return None;
2174        }
2175
2176        // Look up OAuth config: built-in providers or extension-registered OAuth config.
2177        let oauth_result = if provider == "anthropic" {
2178            crate::auth::start_anthropic_oauth().map(|info| (info, None))
2179        } else if provider == "openai-codex" {
2180            crate::auth::start_openai_codex_oauth().map(|info| (info, None))
2181        } else if provider == "google-gemini-cli" {
2182            crate::auth::start_google_gemini_cli_oauth().map(|info| (info, None))
2183        } else if provider == "google-antigravity" {
2184            crate::auth::start_google_antigravity_oauth().map(|info| (info, None))
2185        } else if provider == "github-copilot" || provider == "copilot" {
2186            let client_id = std::env::var("GITHUB_COPILOT_CLIENT_ID").unwrap_or_default();
2187            let copilot_config = crate::auth::CopilotOAuthConfig {
2188                client_id,
2189                ..crate::auth::CopilotOAuthConfig::default()
2190            };
2191            crate::auth::start_copilot_browser_oauth(&copilot_config).map(|info| (info, None))
2192        } else if provider == "gitlab" || provider == "gitlab-duo" {
2193            let client_id = std::env::var("GITLAB_CLIENT_ID").unwrap_or_default();
2194            let base_url = std::env::var("GITLAB_BASE_URL")
2195                .unwrap_or_else(|_| "https://gitlab.com".to_string());
2196            let gitlab_config = crate::auth::GitLabOAuthConfig {
2197                client_id,
2198                base_url,
2199                ..crate::auth::GitLabOAuthConfig::default()
2200            };
2201            crate::auth::start_gitlab_oauth(&gitlab_config).map(|info| (info, None))
2202        } else {
2203            // Check extension providers for OAuth config.
2204            let ext_oauth = extension_oauth_config_for_provider(&self.available_models, &provider);
2205            if let Some(config) = ext_oauth {
2206                crate::auth::start_extension_oauth(&provider, &config)
2207                    .map(|info| (info, Some(config)))
2208            } else {
2209                self.status_message = Some(format!(
2210                    "Login not supported for {provider} (no built-in flow or OAuth config)"
2211                ));
2212                return None;
2213            }
2214        };
2215
2216        match oauth_result {
2217            Ok((info, ext_config)) => {
2218                // Use the pre-bound callback server when the provider already
2219                // created one (e.g. Copilot/GitLab with random port).  Otherwise
2220                // start a new one for localhost redirect URIs (issue #22).
2221                let callback_server = info.callback_server.or_else(|| {
2222                    info.redirect_uri
2223                        .as_deref()
2224                        .filter(|uri| crate::auth::redirect_uri_needs_callback_server(uri))
2225                        .and_then(|uri| crate::auth::start_oauth_callback_server(uri).ok())
2226                });
2227
2228                let mut message = format!(
2229                    "OAuth login: {}\n\nOpen this URL:\n{}\n",
2230                    info.provider, info.url
2231                );
2232                if info.provider == "anthropic" {
2233                    message.push_str(
2234                        "\nWARNING: Anthropic OAuth (Claude Code consumer account) is no longer recommended.\n\
2235Using consumer OAuth tokens outside the official client may violate Anthropic's consumer Terms of Service and can\n\
2236result in account suspension/ban. Prefer using an Anthropic API key (ANTHROPIC_API_KEY) instead.\n",
2237                    );
2238                }
2239                if callback_server.is_some() {
2240                    message.push_str(
2241                        "\nListening for callback — complete authorization in your browser.\n\
2242                         Pi will continue automatically, or you can paste the code manually.",
2243                    );
2244                } else if let Some(instructions) = info.instructions {
2245                    message.push('\n');
2246                    message.push_str(&instructions);
2247                    message.push('\n');
2248                    message.push_str(
2249                        "\nPaste the callback URL or authorization code into Pi to continue.",
2250                    );
2251                } else {
2252                    message.push_str(
2253                        "\nPaste the callback URL or authorization code into Pi to continue.",
2254                    );
2255                }
2256
2257                // Spawn a thread to wait for the callback and inject the code
2258                // via the event channel when the browser redirect arrives.
2259                if let Some(server) = callback_server {
2260                    let event_tx = self.event_tx.clone();
2261                    std::thread::spawn(move || {
2262                        // Block until the callback arrives or the sender is dropped.
2263                        if let Ok(path) = server.rx.recv() {
2264                            let full_url = format!("http://localhost{path}");
2265                            let mut send_result =
2266                                event_tx.try_send(PiMsg::OAuthCallbackReceived(full_url));
2267                            while let Err(asupersync::channel::mpsc::SendError::Full(unsent)) =
2268                                send_result
2269                            {
2270                                std::thread::sleep(std::time::Duration::from_millis(50));
2271                                send_result = event_tx.try_send(unsent);
2272                            }
2273                        }
2274                    });
2275                }
2276
2277                self.messages.push(ConversationMessage {
2278                    role: MessageRole::System,
2279                    content: message,
2280                    thinking: None,
2281                    collapsed: false,
2282                });
2283                self.scroll_to_bottom();
2284                self.pending_oauth = Some(PendingOAuth {
2285                    provider: info.provider,
2286                    kind: PendingLoginKind::OAuth,
2287                    verifier: info.verifier,
2288                    oauth_config: ext_config,
2289                    device_code: None,
2290                    redirect_uri: info.redirect_uri,
2291                });
2292                self.input_mode = InputMode::SingleLine;
2293                self.set_input_height(3);
2294                self.input.focus();
2295                None
2296            }
2297            Err(err) => {
2298                self.status_message = Some(format!("OAuth login failed: {err}"));
2299                None
2300            }
2301        }
2302    }
2303
2304    pub(super) fn handle_slash_logout(&mut self, args: &str) -> Option<Cmd> {
2305        if self.agent_state != AgentState::Idle {
2306            self.status_message = Some("Cannot logout while processing".to_string());
2307            return None;
2308        }
2309
2310        let requested_provider = if args.is_empty() {
2311            self.model_entry.model.provider.clone()
2312        } else {
2313            args.split_whitespace().next().unwrap_or(args).to_string()
2314        };
2315        let requested_provider = requested_provider.trim().to_ascii_lowercase();
2316        let provider = normalize_auth_provider_input(&requested_provider);
2317
2318        let auth_path = crate::config::Config::auth_path();
2319        match crate::auth::AuthStorage::load(auth_path) {
2320            Ok(mut auth) => {
2321                let removed = remove_provider_credentials(&mut auth, &requested_provider);
2322                if let Err(err) = auth.save() {
2323                    self.status_message = Some(err.to_string());
2324                    return None;
2325                }
2326                self.sync_active_provider_credentials(&provider);
2327                if removed {
2328                    self.status_message =
2329                        Some(format!("Removed stored credentials for {provider}."));
2330                } else {
2331                    self.status_message = Some(format!("No stored credentials for {provider}."));
2332                }
2333            }
2334            Err(err) => {
2335                self.status_message = Some(err.to_string());
2336            }
2337        }
2338        None
2339    }
2340
2341    #[allow(clippy::too_many_lines)]
2342    pub(super) fn handle_slash_model(&mut self, args: &str) -> Option<Cmd> {
2343        if args.trim().is_empty() {
2344            self.open_model_selector_configured_only();
2345            return None;
2346        }
2347
2348        if self.agent_state != AgentState::Idle {
2349            self.status_message = Some("Cannot switch models while processing".to_string());
2350            return None;
2351        }
2352
2353        let pattern = args.trim();
2354        let pattern_lower = pattern.to_ascii_lowercase();
2355        let provider_scoped_pattern = split_provider_model_spec(pattern);
2356
2357        let mut exact_matches = Vec::new();
2358        for entry in &self.available_models {
2359            let full = format!("{}/{}", entry.model.provider, entry.model.id);
2360            if full.eq_ignore_ascii_case(pattern)
2361                || entry.model.id.eq_ignore_ascii_case(pattern)
2362                || provider_scoped_pattern.is_some_and(|(provider, model_id)| {
2363                    provider_ids_match(&entry.model.provider, provider)
2364                        && entry.model.id.eq_ignore_ascii_case(model_id)
2365                })
2366            {
2367                exact_matches.push(entry.clone());
2368            }
2369        }
2370
2371        let mut matches = if exact_matches.is_empty() {
2372            let mut fuzzy = Vec::new();
2373            for entry in &self.available_models {
2374                let full = format!("{}/{}", entry.model.provider, entry.model.id);
2375                let full_lower = full.to_ascii_lowercase();
2376                if full_lower.contains(&pattern_lower)
2377                    || entry.model.id.to_ascii_lowercase().contains(&pattern_lower)
2378                {
2379                    fuzzy.push(entry.clone());
2380                }
2381            }
2382            fuzzy
2383        } else {
2384            exact_matches
2385        };
2386
2387        matches.sort_by(|a, b| {
2388            let left = format!("{}/{}", a.model.provider, a.model.id);
2389            let right = format!("{}/{}", b.model.provider, b.model.id);
2390            left.to_ascii_lowercase().cmp(&right.to_ascii_lowercase())
2391        });
2392        matches.dedup_by(|a, b| model_entry_matches(a, b));
2393
2394        if matches.is_empty()
2395            && let Some((provider, model_id)) = pattern.split_once('/')
2396        {
2397            let provider = normalize_auth_provider_input(provider);
2398            let model_id = model_id.trim();
2399            if !provider.is_empty()
2400                && !model_id.is_empty()
2401                && let Some(entry) = crate::models::ad_hoc_model_entry(&provider, model_id)
2402            {
2403                matches.push(entry);
2404            }
2405        }
2406
2407        if matches.is_empty() {
2408            self.status_message = Some(format!("Model not found: {pattern}"));
2409            return None;
2410        }
2411        if matches.len() > 1 {
2412            let preview = matches
2413                .iter()
2414                .take(8)
2415                .map(|m| format!("  - {}/{}", m.model.provider, m.model.id))
2416                .collect::<Vec<_>>()
2417                .join("\n");
2418            self.messages.push(ConversationMessage {
2419                role: MessageRole::System,
2420                content: format!(
2421                    "Ambiguous model pattern \"{pattern}\". Matches:\n{preview}\n\nUse /model provider/id for an exact match."
2422                ),
2423                thinking: None,
2424                collapsed: false,
2425            });
2426            self.scroll_to_bottom();
2427            return None;
2428        }
2429
2430        let next = matches.pop().expect("matches is exactly length 1 here");
2431
2432        let resolved_key_opt = resolve_model_key_from_default_auth(&next);
2433        if model_requires_configured_credential(&next) && resolved_key_opt.is_none() {
2434            self.status_message = Some(format!(
2435                "Missing credentials for provider {}. Run /login {}.",
2436                next.model.provider, next.model.provider
2437            ));
2438            return None;
2439        }
2440
2441        if model_entry_matches(&next, &self.model_entry) {
2442            self.status_message = Some(format!("Current model: {}", self.model));
2443            return None;
2444        }
2445
2446        let provider_impl = match providers::create_provider(&next, self.extensions.as_ref()) {
2447            Ok(provider_impl) => provider_impl,
2448            Err(err) => {
2449                self.status_message = Some(err.to_string());
2450                return None;
2451            }
2452        };
2453
2454        if let Err(message) =
2455            self.switch_active_model(&next, provider_impl, resolved_key_opt.as_deref(), "command")
2456        {
2457            self.status_message = Some(message);
2458            return None;
2459        }
2460
2461        if !self
2462            .available_models
2463            .iter()
2464            .any(|entry| model_entry_matches(entry, &next))
2465        {
2466            self.available_models.push(next.clone());
2467        }
2468
2469        self.status_message = Some(format!("Switched model: {}", self.model));
2470        None
2471    }
2472
2473    pub(super) fn handle_slash_thinking(&mut self, args: &str) -> Option<Cmd> {
2474        let value = args.trim();
2475        if value.is_empty() {
2476            let current = self
2477                .session
2478                .try_lock()
2479                .ok()
2480                .and_then(|guard| guard.header.thinking_level.clone())
2481                .unwrap_or_else(|| ThinkingLevel::Off.to_string());
2482            self.status_message = Some(format!("Thinking level: {current}"));
2483            return None;
2484        }
2485
2486        let level: ThinkingLevel = match value.parse() {
2487            Ok(level) => level,
2488            Err(err) => {
2489                self.status_message = Some(err);
2490                return None;
2491            }
2492        };
2493
2494        let effective_level = self.model_entry.clamp_thinking_level(level);
2495        let Ok(mut session_guard) = self.session.try_lock() else {
2496            self.status_message = Some("Session busy; try again".to_string());
2497            return None;
2498        };
2499        let previous_level = session_thinking_level(&session_guard);
2500        session_guard.header.thinking_level = Some(effective_level.to_string());
2501        let changed = previous_level != Some(effective_level);
2502        if changed {
2503            session_guard.append_thinking_level_change(effective_level.to_string());
2504        }
2505        drop(session_guard);
2506        if changed {
2507            self.spawn_save_session();
2508        }
2509
2510        if let Ok(mut agent_guard) = self.agent.try_lock() {
2511            agent_guard.stream_options_mut().thinking_level = Some(effective_level);
2512        }
2513
2514        self.status_message = Some(format!("Thinking level: {effective_level}"));
2515        None
2516    }
2517
2518    #[allow(clippy::too_many_lines)]
2519    pub(super) fn handle_slash_scoped_models(&mut self, args: &str) -> Option<Cmd> {
2520        let value = args.trim();
2521        if value.is_empty() {
2522            self.messages.push(ConversationMessage {
2523                role: MessageRole::System,
2524                content: self.format_scoped_models_status(),
2525                thinking: None,
2526                collapsed: false,
2527            });
2528            self.scroll_to_last_match("Scoped models");
2529            return None;
2530        }
2531
2532        if value.eq_ignore_ascii_case("clear") {
2533            let previous_patterns = self
2534                .config
2535                .enabled_models
2536                .as_deref()
2537                .unwrap_or(&[])
2538                .to_vec();
2539            self.config.enabled_models = Some(Vec::new());
2540            self.model_scope.clear();
2541
2542            let global_dir = Config::global_dir();
2543            let patch = json!({ "enabled_models": [] });
2544            let cleared_msg = if previous_patterns.is_empty() {
2545                "Scoped models cleared (was: all models)".to_string()
2546            } else {
2547                format!(
2548                    "Scoped models cleared: removed {} pattern(s) (was: {})",
2549                    previous_patterns.len(),
2550                    previous_patterns.join(", ")
2551                )
2552            };
2553            if let Err(err) = Config::patch_settings_with_roots(
2554                SettingsScope::Project,
2555                &global_dir,
2556                &self.cwd,
2557                patch,
2558            ) {
2559                tracing::warn!("Failed to persist enabled_models: {err}");
2560                self.status_message = Some(format!("{cleared_msg} (not saved: {err})"));
2561            } else {
2562                self.status_message = Some(cleared_msg);
2563            }
2564            return None;
2565        }
2566
2567        let patterns = parse_scoped_model_patterns(value);
2568        if patterns.is_empty() {
2569            self.status_message = Some("Usage: /scoped-models [patterns|clear]".to_string());
2570            return None;
2571        }
2572
2573        let resolved = match resolve_scoped_model_entries(&patterns, &self.available_models) {
2574            Ok(resolved) => resolved,
2575            Err(err) => {
2576                self.status_message =
2577                    Some(format!("{err}\n  Example: /scoped-models gpt-4*,claude-3*"));
2578                return None;
2579            }
2580        };
2581
2582        self.model_scope = resolved;
2583        self.config.enabled_models = Some(patterns.clone());
2584
2585        let match_count = self.model_scope.len();
2586
2587        // Build a preview of matched models for the conversation pane.
2588        let mut preview = String::new();
2589        if match_count == 0 {
2590            let _ = writeln!(
2591                preview,
2592                "Warning: No models matched patterns: {}",
2593                patterns.join(", ")
2594            );
2595            let _ = writeln!(preview, "Ctrl+P cycling will use all available models.");
2596        } else {
2597            let _ = writeln!(preview, "Matching {match_count} model(s):");
2598            let mut model_names: Vec<String> = self
2599                .model_scope
2600                .iter()
2601                .map(|e| format!("{}/{}", e.model.provider, e.model.id))
2602                .collect();
2603            model_names.sort_by_key(|s| s.to_ascii_lowercase());
2604            model_names.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
2605            for name in &model_names {
2606                let _ = writeln!(preview, "  {name}");
2607            }
2608        }
2609        let _ = writeln!(
2610            preview,
2611            "Patterns saved. Press Ctrl+P to cycle through matched models."
2612        );
2613
2614        self.messages.push(ConversationMessage {
2615            role: MessageRole::System,
2616            content: preview,
2617            thinking: None,
2618            collapsed: false,
2619        });
2620        self.scroll_to_bottom();
2621
2622        let status = if match_count == 0 {
2623            "Scoped models updated: 0 matched; cycling will use all available models".to_string()
2624        } else {
2625            format!("Scoped models updated: {match_count} matched")
2626        };
2627        let global_dir = Config::global_dir();
2628        let patch = json!({ "enabled_models": patterns });
2629        if let Err(err) =
2630            Config::patch_settings_with_roots(SettingsScope::Project, &global_dir, &self.cwd, patch)
2631        {
2632            tracing::warn!("Failed to persist enabled_models: {err}");
2633            self.status_message = Some(format!("{status} (not saved: {err})"));
2634        } else {
2635            self.status_message = Some(status);
2636        }
2637        None
2638    }
2639
2640    pub(super) fn handle_slash_reload(&mut self) -> Option<Cmd> {
2641        if self.agent_state != AgentState::Idle {
2642            self.status_message = Some("Cannot reload while processing".to_string());
2643            return None;
2644        }
2645
2646        let config = self.config.clone();
2647        let cli = self.resource_cli.clone();
2648        let cwd = self.cwd.clone();
2649        let event_tx = self.event_tx.clone();
2650        let extensions = self.extensions.clone();
2651        let runtime_handle = self.runtime_handle.clone();
2652        let task_cx = Cx::current().unwrap_or_else(Cx::for_request);
2653
2654        runtime_handle.spawn(async move {
2655            let manager = PackageManager::new(cwd.clone());
2656            match ResourceLoader::load(&manager, &cwd, &config, &cli).await {
2657                Ok(mut resources) => {
2658                    if let Some(manager) = extensions {
2659                        let discovered = manager.discover_resources(&cwd, "reload").await;
2660                        if !discovered.is_empty() {
2661                            if let Err(err) = resources.extend_with_paths(&cwd, &discovered) {
2662                                tracing::warn!(
2663                                    event = "pi.resources.reload.extension_paths_failed",
2664                                    error = %err,
2665                                    "Failed to apply extension-discovered resource paths"
2666                                );
2667                            }
2668                        }
2669                    }
2670
2671                    let models_error =
2672                        match crate::auth::AuthStorage::load_async(Config::auth_path()).await {
2673                            Ok(auth) => {
2674                                let models_path = default_models_path(&Config::global_dir());
2675                                let registry = ModelRegistry::load(&auth, Some(models_path));
2676                                registry.error().map(ToString::to_string)
2677                            }
2678                            Err(err) => Some(format!("Failed to load auth.json: {err}")),
2679                        };
2680
2681                    let (diagnostics, diag_count) =
2682                        build_reload_diagnostics(models_error, &resources);
2683
2684                    let mut status = format!(
2685                        "Reloaded resources: {} skills, {} prompts, {} themes",
2686                        resources.skills().len(),
2687                        resources.prompts().len(),
2688                        resources.themes().len()
2689                    );
2690                    if diag_count > 0 {
2691                        let _ = write!(status, " ({diag_count} diagnostics)");
2692                    }
2693
2694                    let _ = crate::interactive::enqueue_pi_event(
2695                        &event_tx,
2696                        &task_cx,
2697                        PiMsg::ResourcesReloaded {
2698                            resources,
2699                            status,
2700                            diagnostics,
2701                        },
2702                    )
2703                    .await;
2704                }
2705                Err(err) => {
2706                    let _ = crate::interactive::enqueue_pi_event(
2707                        &event_tx,
2708                        &task_cx,
2709                        PiMsg::AgentError(format!("Failed to reload resources: {err}")),
2710                    )
2711                    .await;
2712                }
2713            }
2714        });
2715
2716        self.status_message = Some("Reloading resources...".to_string());
2717        None
2718    }
2719
2720    #[allow(clippy::too_many_lines)]
2721    pub(super) fn handle_slash_template(&mut self, args: &str) -> Option<Cmd> {
2722        if self.agent_state != AgentState::Idle {
2723            self.status_message = Some("Cannot expand template while processing".to_string());
2724            return None;
2725        }
2726
2727        let trimmed = args.trim();
2728        if trimmed.is_empty() {
2729            let templates = self.resources.prompts();
2730            if templates.is_empty() {
2731                self.status_message = Some("No prompt templates loaded".to_string());
2732                return None;
2733            }
2734
2735            let mut listing = String::from("Available prompt templates:\n");
2736            for template in templates {
2737                if template.description.trim().is_empty() {
2738                    let _ = writeln!(listing, "  /{}", template.name);
2739                } else {
2740                    let _ = writeln!(listing, "  /{} - {}", template.name, template.description);
2741                }
2742            }
2743
2744            self.messages.push(ConversationMessage {
2745                role: MessageRole::System,
2746                content: listing,
2747                thinking: None,
2748                collapsed: false,
2749            });
2750            self.scroll_to_last_match("Available prompt templates");
2751            return None;
2752        }
2753
2754        let history_entry = format!("/template {trimmed}");
2755
2756        let (name, rest) = trimmed
2757            .split_once(char::is_whitespace)
2758            .unwrap_or((trimmed, ""));
2759        let name = name.trim_start_matches('/');
2760        if name.is_empty() {
2761            self.status_message = Some("Usage: /template <name> [args]".to_string());
2762            return None;
2763        }
2764
2765        let raw_input = if rest.trim().is_empty() {
2766            format!("/{name}")
2767        } else {
2768            format!("/{name} {rest}")
2769        };
2770
2771        let expanded = {
2772            let templates = self.resources.prompts();
2773            if templates.iter().all(|template| template.name != name) {
2774                self.status_message = Some(format!("Template not found: {name}"));
2775                return None;
2776            }
2777            crate::resources::expand_prompt_template(&raw_input, templates)
2778        };
2779
2780        if expanded.trim().is_empty() {
2781            self.status_message = Some("Template expansion produced empty output".to_string());
2782            return None;
2783        }
2784
2785        let (message_without_refs, file_refs) = self.extract_file_references(&expanded);
2786        let message_for_agent = message_without_refs.trim().to_string();
2787
2788        if !file_refs.is_empty() {
2789            let auto_resize = self
2790                .config
2791                .images
2792                .as_ref()
2793                .and_then(|images| images.auto_resize)
2794                .unwrap_or(true);
2795
2796            let processed = match process_file_arguments(&file_refs, &self.cwd, auto_resize) {
2797                Ok(processed) => processed,
2798                Err(err) => {
2799                    self.status_message = Some(err.to_string());
2800                    return None;
2801                }
2802            };
2803
2804            let mut text = processed.text;
2805            if !message_for_agent.trim().is_empty() {
2806                text.push_str(&message_for_agent);
2807            }
2808
2809            let mut content = Vec::new();
2810            if !text.trim().is_empty() {
2811                content.push(ContentBlock::Text(TextContent::new(text)));
2812            }
2813            for image in processed.images {
2814                content.push(ContentBlock::Image(image));
2815            }
2816
2817            if content.is_empty() {
2818                self.status_message =
2819                    Some("Template expansion produced no usable content".to_string());
2820                return None;
2821            }
2822
2823            self.history.push(history_entry);
2824            let display = super::conversation::content_blocks_to_text(&content);
2825            return self.submit_content_with_display(content, &display);
2826        }
2827
2828        if message_for_agent.is_empty() {
2829            self.status_message = Some("Template expansion produced empty output".to_string());
2830            return None;
2831        }
2832
2833        self.history.push(history_entry);
2834        let content = vec![ContentBlock::Text(TextContent::new(message_for_agent))];
2835        let display = super::conversation::content_blocks_to_text(&content);
2836        self.submit_content_with_display(content, &display)
2837    }
2838}
2839
2840#[cfg(test)]
2841mod tests {
2842    use super::{parse_bash_command, parse_extension_command, should_show_startup_oauth_hint};
2843    use crate::auth::{AuthCredential, AuthStorage};
2844    use crate::models::ModelEntry;
2845    use crate::provider::{InputType, Model, ModelCost};
2846    use std::collections::{HashMap, HashSet};
2847    use std::time::{SystemTime, UNIX_EPOCH};
2848
2849    fn empty_auth_storage() -> AuthStorage {
2850        let nonce = SystemTime::now()
2851            .duration_since(UNIX_EPOCH)
2852            .expect("system clock before unix epoch")
2853            .as_nanos();
2854        let path = std::env::temp_dir().join(format!("pi_auth_storage_test_{nonce}.json"));
2855        AuthStorage::load(path).expect("load empty auth storage")
2856    }
2857
2858    fn test_model_entry(provider: &str, id: &str) -> ModelEntry {
2859        ModelEntry {
2860            model: Model {
2861                id: id.to_string(),
2862                name: id.to_string(),
2863                api: "openai-responses".to_string(),
2864                provider: provider.to_string(),
2865                base_url: "https://example.test/v1".to_string(),
2866                reasoning: true,
2867                input: vec![InputType::Text],
2868                cost: ModelCost {
2869                    input: 0.0,
2870                    output: 0.0,
2871                    cache_read: 0.0,
2872                    cache_write: 0.0,
2873                },
2874                context_window: 128_000,
2875                max_tokens: 8_192,
2876                headers: HashMap::new(),
2877            },
2878            api_key: Some("test-key".to_string()),
2879            headers: HashMap::new(),
2880            auth_header: true,
2881            compat: None,
2882            oauth_config: None,
2883        }
2884    }
2885
2886    #[test]
2887    fn plan_session_thinking_sync_repairs_missing_header_when_model_clamps_runtime_level() {
2888        let mut target = test_model_entry("acme", "plain-model");
2889        target.model.reasoning = false;
2890
2891        let plan =
2892            super::plan_session_thinking_sync(None, crate::model::ThinkingLevel::High, &target);
2893
2894        assert_eq!(plan.effective, crate::model::ThinkingLevel::Off);
2895        assert!(plan.thinking_changed);
2896        assert!(plan.persist_needed);
2897    }
2898
2899    #[test]
2900    fn plan_session_thinking_sync_repairs_invalid_header_without_fake_runtime_change() {
2901        let mut target = test_model_entry("acme", "plain-model");
2902        target.model.reasoning = false;
2903
2904        let plan = super::plan_session_thinking_sync(
2905            Some("definitely-invalid"),
2906            crate::model::ThinkingLevel::Off,
2907            &target,
2908        );
2909
2910        assert_eq!(plan.effective, crate::model::ThinkingLevel::Off);
2911        assert!(!plan.thinking_changed);
2912        assert!(plan.persist_needed);
2913    }
2914
2915    #[test]
2916    fn parse_ext_cmd_basic() {
2917        let result = parse_extension_command("/deploy");
2918        assert_eq!(result, Some(("deploy".to_string(), "")));
2919    }
2920
2921    #[test]
2922    fn parse_ext_cmd_with_args() {
2923        let result = parse_extension_command("/deploy staging fast");
2924        assert_eq!(result, Some(("deploy".to_string(), "staging fast")));
2925    }
2926
2927    #[test]
2928    fn parse_ext_cmd_builtin_filtered() {
2929        assert!(parse_extension_command("/help").is_none());
2930        assert!(parse_extension_command("/clear").is_none());
2931        assert!(parse_extension_command("/model").is_none());
2932        assert!(parse_extension_command("/exit").is_none());
2933        assert!(parse_extension_command("/compact").is_none());
2934    }
2935
2936    #[test]
2937    fn parse_ext_cmd_no_slash() {
2938        assert!(parse_extension_command("deploy").is_none());
2939        assert!(parse_extension_command("hello world").is_none());
2940    }
2941
2942    #[test]
2943    fn parse_ext_cmd_empty_slash() {
2944        assert!(parse_extension_command("/").is_none());
2945        assert!(parse_extension_command("/  ").is_none());
2946    }
2947
2948    #[test]
2949    fn parse_ext_cmd_whitespace_trimming() {
2950        let result = parse_extension_command("  /deploy  arg1  arg2  ");
2951        assert_eq!(result, Some(("deploy".to_string(), "arg1  arg2")));
2952    }
2953
2954    #[test]
2955    fn parse_ext_cmd_single_arg() {
2956        let result = parse_extension_command("/greet world");
2957        assert_eq!(result, Some(("greet".to_string(), "world")));
2958    }
2959
2960    #[test]
2961    fn parse_ext_cmd_preserves_raw_argument_spacing_and_quotes() {
2962        let result = parse_extension_command(r#"/deploy   --message "hello world"   --force"#);
2963        assert_eq!(
2964            result,
2965            Some(("deploy".to_string(), r#"--message "hello world"   --force"#))
2966        );
2967    }
2968
2969    #[test]
2970    fn parse_bash_command_distinguishes_exclusion() {
2971        let (command, exclude) = parse_bash_command("! ls -la").expect("bang command");
2972        assert_eq!(command, "ls -la");
2973        assert!(!exclude);
2974
2975        let (command, exclude) = parse_bash_command("!! ls -la").expect("double bang command");
2976        assert_eq!(command, "ls -la");
2977        assert!(exclude);
2978    }
2979
2980    #[test]
2981    fn parse_bash_command_empty_bang() {
2982        assert!(parse_bash_command("!").is_none());
2983        assert!(parse_bash_command("!!").is_none());
2984        assert!(parse_bash_command("!  ").is_none());
2985    }
2986
2987    #[test]
2988    fn parse_bash_command_no_bang() {
2989        assert!(parse_bash_command("ls -la").is_none());
2990        assert!(parse_bash_command("").is_none());
2991    }
2992
2993    #[test]
2994    fn parse_bash_command_leading_whitespace() {
2995        let (cmd, exclude) = parse_bash_command("  ! echo hi").expect("should parse");
2996        assert_eq!(cmd, "echo hi");
2997        assert!(!exclude);
2998    }
2999
3000    #[test]
3001    fn startup_hint_is_hidden_when_priority_provider_is_available() {
3002        let mut auth = empty_auth_storage();
3003        auth.set(
3004            "anthropic",
3005            AuthCredential::ApiKey {
3006                key: "test-key".to_string(),
3007            },
3008        );
3009        assert!(!should_show_startup_oauth_hint(&auth));
3010    }
3011
3012    #[test]
3013    fn startup_hint_is_hidden_when_non_oauth_provider_is_available() {
3014        let mut auth = empty_auth_storage();
3015        auth.set(
3016            "openai",
3017            AuthCredential::ApiKey {
3018                key: "test-openai-key".to_string(),
3019            },
3020        );
3021        assert!(!should_show_startup_oauth_hint(&auth));
3022    }
3023
3024    #[test]
3025    fn startup_hint_copy_no_longer_uses_front_and_center_phrase() {
3026        let auth = empty_auth_storage();
3027        let hint = super::format_startup_oauth_hint(&auth);
3028        assert!(hint.contains("No provider credentials were detected."));
3029        assert!(!hint.contains("front and center"));
3030    }
3031
3032    #[test]
3033    fn builtin_login_providers_cover_legacy_oauth_registry() {
3034        let login_oauth: HashSet<&str> = super::BUILTIN_LOGIN_PROVIDERS
3035            .iter()
3036            .filter_map(|(provider, mode)| (*mode == "OAuth").then_some(*provider))
3037            .collect();
3038
3039        // Legacy pi-mono OAuth provider registry (packages/ai/src/utils/oauth/index.ts)
3040        // includes exactly these built-ins.
3041        let legacy_oauth = [
3042            "anthropic",
3043            "openai-codex",
3044            "google-gemini-cli",
3045            "google-antigravity",
3046            "github-copilot",
3047        ];
3048
3049        let missing: Vec<&str> = legacy_oauth
3050            .iter()
3051            .copied()
3052            .filter(|provider| !login_oauth.contains(provider))
3053            .collect();
3054
3055        assert!(
3056            missing.is_empty(),
3057            "missing legacy OAuth providers in /login table: {}",
3058            missing.join(", ")
3059        );
3060
3061        assert!(
3062            login_oauth.contains("kimi-for-coding"),
3063            "kimi-for-coding should remain available in /login OAuth providers"
3064        );
3065    }
3066
3067    #[test]
3068    fn metadata_backed_api_key_prompt_supports_openai_compatible_presets() {
3069        let prompt = super::api_key_login_prompt("openrouter").expect("openrouter prompt");
3070        assert!(prompt.contains("API key login: openrouter"));
3071        assert!(prompt.contains("OpenRouter"));
3072        assert!(prompt.contains("https://openrouter.ai/api/v1"));
3073        assert!(prompt.contains("OPENROUTER_API_KEY"));
3074    }
3075
3076    #[test]
3077    fn dedicated_login_flows_still_take_priority_over_generic_api_key_prompts() {
3078        assert!(super::api_key_login_prompt("anthropic").is_none());
3079        assert!(super::api_key_login_prompt("kimi-for-coding").is_none());
3080    }
3081
3082    #[test]
3083    fn login_provider_listing_includes_metadata_backed_api_key_providers() {
3084        let auth = empty_auth_storage();
3085        let listing = super::format_login_provider_listing(&auth, &[]);
3086        assert!(listing.contains("openrouter"));
3087        assert!(listing.contains("cohere"));
3088        assert!(listing.contains("API key"));
3089    }
3090
3091    #[test]
3092    fn model_entry_matches_provider_aliases_case_insensitively() {
3093        let left = test_model_entry("openrouter", "openai/gpt-4o-mini");
3094        let right = test_model_entry("open-router", "openai/gpt-4o-mini");
3095        assert!(super::model_entry_matches(&left, &right));
3096    }
3097
3098    #[test]
3099    fn provider_ids_match_normalizes_aliases() {
3100        assert!(super::provider_ids_match("openrouter", "open-router"));
3101        assert!(super::provider_ids_match("google-gemini-cli", "gemini-cli"));
3102        assert!(super::provider_ids_match("kimi-for-coding", "kimi-code"));
3103        assert!(!super::provider_ids_match("openai", "anthropic"));
3104    }
3105
3106    #[test]
3107    fn normalize_auth_provider_input_maps_kimi_code_alias() {
3108        assert_eq!(
3109            super::normalize_auth_provider_input("kimi-code"),
3110            "kimi-for-coding"
3111        );
3112    }
3113
3114    #[test]
3115    fn resolve_scoped_model_entries_dedupes_provider_alias_variants() {
3116        let available = vec![
3117            test_model_entry("openrouter", "openai/gpt-4o-mini"),
3118            test_model_entry("open-router", "openai/gpt-4o-mini"),
3119        ];
3120        let patterns = vec!["openrouter/openai/gpt-4o-mini".to_string()];
3121        let resolved = super::resolve_scoped_model_entries(&patterns, &available)
3122            .expect("resolve scoped models");
3123        assert_eq!(resolved.len(), 1);
3124        assert_eq!(resolved[0].model.id, "openai/gpt-4o-mini");
3125    }
3126
3127    #[test]
3128    fn save_provider_credential_canonicalizes_alias_input() {
3129        let mut auth = empty_auth_storage();
3130        super::save_provider_credential(
3131            &mut auth,
3132            "gemini",
3133            AuthCredential::ApiKey {
3134                key: "new-google-token".to_string(),
3135            },
3136        );
3137
3138        assert!(auth.get("gemini").is_none());
3139        assert!(matches!(
3140            auth.get("google"),
3141            Some(AuthCredential::ApiKey { key }) if key == "new-google-token"
3142        ));
3143    }
3144
3145    #[test]
3146    fn resolve_model_key_with_auth_prefers_stored_key_over_inline_key() {
3147        let mut auth = empty_auth_storage();
3148        auth.set(
3149            "openai",
3150            AuthCredential::ApiKey {
3151                key: "stored-auth-sample".to_string(),
3152            },
3153        );
3154
3155        let mut entry = test_model_entry("openai", "gpt-4o-mini");
3156        entry.api_key = Some("inline-model-sample".to_string());
3157
3158        assert_eq!(
3159            super::resolve_model_key_with_auth(&auth, &entry).as_deref(),
3160            Some("stored-auth-sample")
3161        );
3162    }
3163
3164    #[test]
3165    fn resolve_model_key_with_auth_falls_back_to_inline_key() {
3166        let auth = empty_auth_storage();
3167        let mut entry = test_model_entry("openai", "gpt-4o-mini");
3168        entry.api_key = Some("inline-model-sample".to_string());
3169
3170        assert_eq!(
3171            super::resolve_model_key_with_auth(&auth, &entry).as_deref(),
3172            Some("inline-model-sample")
3173        );
3174    }
3175
3176    #[test]
3177    fn remove_provider_credentials_removes_alias_entries() {
3178        let mut auth = empty_auth_storage();
3179        auth.set(
3180            "google",
3181            AuthCredential::ApiKey {
3182                key: "google-key".to_string(),
3183            },
3184        );
3185        auth.set(
3186            "gemini",
3187            AuthCredential::ApiKey {
3188                key: "gemini-key".to_string(),
3189            },
3190        );
3191
3192        assert!(super::remove_provider_credentials(&mut auth, "gemini"));
3193        assert!(auth.get("google").is_none());
3194        assert!(auth.get("gemini").is_none());
3195    }
3196
3197    #[test]
3198    fn extension_oauth_config_selection_skips_non_oauth_entries() {
3199        let mut no_oauth = test_model_entry("ext-provider", "model-a");
3200        no_oauth.oauth_config = None;
3201        let mut with_oauth = test_model_entry("ext-provider", "model-b");
3202        with_oauth.oauth_config = Some(crate::models::OAuthConfig {
3203            auth_url: "https://example.test/oauth/authorize".to_string(),
3204            token_url: "https://example.test/oauth/token".to_string(),
3205            scopes: vec!["scope:a".to_string()],
3206            client_id: "client-id".to_string(),
3207            redirect_uri: Some("http://localhost/callback".to_string()),
3208        });
3209
3210        let selected =
3211            super::extension_oauth_config_for_provider(&[no_oauth, with_oauth], "ext-provider");
3212        let selected = selected.expect("expected oauth config");
3213        assert_eq!(selected.auth_url, "https://example.test/oauth/authorize");
3214        assert_eq!(selected.token_url, "https://example.test/oauth/token");
3215        assert_eq!(selected.client_id, "client-id");
3216        assert_eq!(selected.scopes, vec!["scope:a".to_string()]);
3217        assert_eq!(
3218            selected.redirect_uri.as_deref(),
3219            Some("http://localhost/callback")
3220        );
3221    }
3222}