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