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#[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 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 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 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 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 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 #[allow(clippy::too_many_lines)]
1510 pub(super) fn handle_slash_command(&mut self, cmd: SlashCommand, args: &str) -> Option<Cmd> {
1511 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 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 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 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 if let Some(server) = callback_server {
2260 let event_tx = self.event_tx.clone();
2261 std::thread::spawn(move || {
2262 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 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 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}