1use super::commands::model_entry_matches;
2use super::*;
3
4impl PiApp {
5 pub(super) fn format_hotkeys(&self) -> String {
9 use crate::keybindings::ActionCategory;
10 use std::fmt::Write;
11
12 let mut output = String::new();
13 let _ = writeln!(output, "Keyboard Shortcuts");
14 let _ = writeln!(output, "==================");
15 let _ = writeln!(output);
16 let _ = writeln!(
17 output,
18 "Config: {}",
19 KeyBindings::user_config_path().display()
20 );
21 let _ = writeln!(output);
22
23 for category in ActionCategory::all() {
24 let actions: Vec<_> = self.keybindings.iter_category(*category).collect();
25
26 if actions.iter().all(|(_, bindings)| bindings.is_empty()) {
28 continue;
29 }
30
31 let _ = writeln!(output, "## {}", category.display_name());
32 let _ = writeln!(output);
33
34 for (action, bindings) in actions {
35 if bindings.is_empty() {
36 continue;
37 }
38
39 let keys: Vec<_> = bindings
41 .iter()
42 .map(std::string::ToString::to_string)
43 .collect();
44 let keys_str = keys.join(", ");
45
46 let _ = writeln!(output, " {:20} {}", keys_str, action.display_name());
47 }
48 let _ = writeln!(output);
49 }
50
51 output
52 }
53
54 pub(super) fn resolve_action(&self, candidates: &[AppAction]) -> Option<AppAction> {
55 let &first = candidates.first()?;
56
57 if candidates.contains(&AppAction::Exit)
61 && self.agent_state == AgentState::Idle
62 && self.input.value().is_empty()
63 {
64 return Some(AppAction::Exit);
65 }
66
67 Some(first)
68 }
69
70 pub(super) fn handle_capability_prompt_key(&mut self, key: &KeyMsg) -> Option<Cmd> {
71 let prompt = self.capability_prompt.as_mut()?;
72
73 match key.key_type {
74 KeyType::Right | KeyType::Tab => prompt.focus_next(),
76 KeyType::Left => prompt.focus_prev(),
77 KeyType::Runes if key.runes == ['l'] => prompt.focus_next(),
78 KeyType::Runes if key.runes == ['h'] => prompt.focus_prev(),
79
80 KeyType::Enter => {
82 let action = prompt.selected_action();
83 let response = ExtensionUiResponse {
84 id: prompt.request.id.clone(),
85 value: Some(Value::Bool(action.is_allow())),
86 cancelled: false,
87 };
88 if action.is_persistent() {
90 if let Ok(mut store) = crate::permissions::PermissionStore::open_default() {
91 let _ = store.record(
92 &prompt.extension_id,
93 &prompt.capability,
94 action.is_allow(),
95 );
96 }
97 }
98 self.capability_prompt = None;
99 self.send_extension_ui_response(response);
100 }
101
102 KeyType::Esc => {
104 let response = ExtensionUiResponse {
105 id: prompt.request.id.clone(),
106 value: Some(Value::Bool(false)),
107 cancelled: true,
108 };
109 self.capability_prompt = None;
110 self.send_extension_ui_response(response);
111 }
112
113 _ => {}
114 }
115
116 None
117 }
118
119 pub(super) fn handle_paste_event(&mut self, key: &KeyMsg) -> bool {
120 if key.key_type != KeyType::Runes || key.runes.is_empty() {
121 return false;
122 }
123
124 let pasted: String = key.runes.iter().collect();
125 let Some((insert, count)) = self.normalize_pasted_paths(&pasted) else {
126 return false;
127 };
128
129 self.input.insert_string(&insert);
130 if count > 0 {
131 self.status_message = Some(format!(
132 "Attached {} file{}",
133 count,
134 if count == 1 { "" } else { "s" }
135 ));
136 }
137 true
138 }
139
140 fn normalize_pasted_paths(&self, pasted: &str) -> Option<(String, usize)> {
141 let mut refs = Vec::new();
142 for line in pasted.lines() {
143 let trimmed = line.trim();
144 if trimmed.is_empty() {
145 continue;
146 }
147 let path = self.normalize_pasted_path(trimmed)?;
148 refs.push(path);
149 }
150
151 if refs.is_empty() {
152 return None;
153 }
154
155 let mut insert = refs
156 .iter()
157 .map(|path| format_file_ref(path))
158 .collect::<Vec<_>>()
159 .join(" ");
160 if !insert.ends_with(' ') {
161 insert.push(' ');
162 }
163
164 Some((insert, refs.len()))
165 }
166
167 fn normalize_pasted_path(&self, raw: &str) -> Option<String> {
168 let trimmed = raw.trim();
169 if trimmed.is_empty() || trimmed.starts_with('@') {
170 return None;
171 }
172
173 let unquoted = strip_wrapping_quotes(trimmed);
174 let unescaped = unescape_dragged_path(unquoted);
175 let path = file_url_to_path(&unescaped).unwrap_or_else(|| PathBuf::from(&unescaped));
176 let resolved = resolve_read_path(path.to_string_lossy().as_ref(), &self.cwd);
177 if !resolved.exists() {
178 return None;
179 }
180
181 Some(path_for_display(&resolved, &self.cwd))
182 }
183
184 pub(super) fn insert_file_ref_path(&mut self, path: &Path) {
185 let display = path_for_display(path, &self.cwd);
186 let mut insert_text = format_file_ref(&display);
187 if !insert_text.ends_with(' ') {
188 insert_text.push(' ');
189 }
190 self.input.insert_string(&insert_text);
191 }
192
193 #[allow(clippy::missing_const_for_fn)]
194 pub(super) fn paste_image_from_clipboard() -> Option<PathBuf> {
195 #[cfg(all(feature = "clipboard", feature = "image-resize"))]
196 {
197 use image::ImageEncoder;
198
199 let mut clipboard = ArboardClipboard::new().ok()?;
200 let image = clipboard.get_image().ok()?;
201
202 let width = u32::try_from(image.width).ok()?;
203 let height = u32::try_from(image.height).ok()?;
204 let bytes = image.bytes.into_owned();
205 let width_usize = usize::try_from(width).ok()?;
206 let height_usize = usize::try_from(height).ok()?;
207 let expected = width_usize.checked_mul(height_usize)?.checked_mul(4)?;
208 if bytes.len() != expected {
209 return None;
210 }
211
212 let mut temp_file = tempfile::Builder::new()
213 .prefix("pi-paste-")
214 .suffix(".png")
215 .tempfile()
216 .ok()?;
217 let encoder = image::codecs::png::PngEncoder::new(&mut temp_file);
218 if encoder
219 .write_image(&bytes, width, height, image::ExtendedColorType::Rgba8)
220 .is_err()
221 {
222 return None;
223 }
224 let (_file, path) = temp_file.keep().ok()?;
225 Some(path)
226 }
227
228 #[cfg(not(all(feature = "clipboard", feature = "image-resize")))]
229 {
230 None
231 }
232 }
233
234 pub(super) fn open_external_editor(&self) -> std::io::Result<String> {
239 use std::io::Write;
240
241 let editor = std::env::var("VISUAL")
243 .or_else(|_| std::env::var("EDITOR"))
244 .unwrap_or_else(|_| "vi".to_string());
245
246 let mut temp_file = tempfile::NamedTempFile::new()?;
248 let current_text = self.input.value();
249 temp_file.write_all(current_text.as_bytes())?;
250 temp_file.flush()?;
251
252 let temp_path = temp_file.path().to_path_buf();
253
254 #[cfg(unix)]
257 let status = std::process::Command::new("sh")
258 .args(["-c", &format!("{editor} \"$1\"")])
259 .arg("--") .arg(&temp_path)
261 .status()?;
262
263 #[cfg(not(unix))]
264 let status = std::process::Command::new("cmd")
265 .args(["/c", &format!("{} \"{}\"", editor, temp_path.display())])
266 .status()?;
267
268 if !status.success() {
269 return Err(std::io::Error::other(format!(
270 "Editor exited with status: {status}"
271 )));
272 }
273
274 let new_text = std::fs::read_to_string(&temp_path)?;
276 Ok(new_text)
277 }
278
279 fn navigate_history_back(&mut self) {
281 if !self.history.has_entries() {
282 return;
283 }
284
285 self.history.cursor_up();
286 self.apply_history_selection();
287 }
288
289 fn navigate_history_forward(&mut self) {
291 if self.history.cursor_is_empty() {
293 return;
294 }
295
296 self.history.cursor_down();
297 self.apply_history_selection();
298 }
299
300 fn apply_history_selection(&mut self) {
301 let selected = self.history.selected_value();
302 if selected.is_empty() {
303 self.input.reset();
304 } else {
305 self.input.set_value(selected);
306 }
307 }
308
309 fn handle_double_escape_action(&mut self) -> (bool, Option<Cmd>) {
310 let now = std::time::Instant::now();
311 if let Some(last_time) = self.last_escape_time {
312 if now.duration_since(last_time) < std::time::Duration::from_millis(500) {
313 self.last_escape_time = None;
314 return (true, self.trigger_double_escape_action());
315 }
316 }
317 self.last_escape_time = Some(now);
318 (false, None)
319 }
320
321 fn trigger_double_escape_action(&mut self) -> Option<Cmd> {
322 let raw_action = self
323 .config
324 .double_escape_action
325 .as_deref()
326 .unwrap_or("tree")
327 .trim();
328 let action = raw_action.to_ascii_lowercase();
329 match action.as_str() {
330 "tree" => self.handle_slash_command(SlashCommand::Tree, ""),
331 "fork" => self.handle_slash_command(SlashCommand::Fork, ""),
332 _ => {
333 self.status_message = Some(format!(
334 "Unknown doubleEscapeAction: {raw_action} (expected tree or fork)"
335 ));
336 self.handle_slash_command(SlashCommand::Tree, "")
337 }
338 }
339 }
340
341 #[allow(clippy::too_many_lines)]
342 pub fn cycle_model(&mut self, delta: i32) {
343 if self.agent_state != AgentState::Idle {
344 self.status_message = Some("Cannot switch models while processing".to_string());
345 return;
346 }
347
348 let scope_configured = self
349 .config
350 .enabled_models
351 .as_ref()
352 .is_some_and(|patterns| !patterns.is_empty());
353 let use_scope = scope_configured || !self.model_scope.is_empty();
354 let mut fell_back_to_available = false;
355 let mut candidates = if use_scope {
356 self.model_scope.clone()
357 } else {
358 self.available_models.clone()
359 };
360 if use_scope && candidates.is_empty() {
361 candidates.clone_from(&self.available_models);
362 fell_back_to_available = true;
363 }
364
365 candidates.sort_by(|a, b| {
366 let left = format!("{}/{}", a.model.provider, a.model.id);
367 let right = format!("{}/{}", b.model.provider, b.model.id);
368 left.cmp(&right)
369 });
370 candidates.dedup_by(|left, right| model_entry_matches(left, right));
371
372 if candidates.is_empty() {
373 self.status_message = Some("No models available".to_string());
374 return;
375 }
376
377 let current_index = candidates
378 .iter()
379 .position(|entry| model_entry_matches(entry, &self.model_entry));
380
381 let next_index = current_index.map_or_else(
382 || {
383 if delta >= 0 { 0 } else { candidates.len() - 1 }
384 },
385 |idx| {
386 if delta >= 0 {
387 (idx + 1) % candidates.len()
388 } else {
389 idx.checked_sub(1).unwrap_or(candidates.len() - 1)
390 }
391 },
392 );
393
394 let next = candidates[next_index].clone();
395
396 if model_entry_matches(&next, &self.model_entry) {
397 self.status_message = Some(if use_scope && !fell_back_to_available {
398 "Only one model in scope".to_string()
399 } else {
400 "Only one model available".to_string()
401 });
402 return;
403 }
404
405 let provider_impl = match providers::create_provider(&next, self.extensions.as_ref()) {
406 Ok(provider_impl) => provider_impl,
407 Err(err) => {
408 self.status_message = Some(err.to_string());
409 return;
410 }
411 };
412 let resolved_key_opt = super::commands::resolve_model_key_from_default_auth(&next);
413 if super::commands::model_requires_configured_credential(&next)
414 && resolved_key_opt.is_none()
415 {
416 self.status_message = Some(format!(
417 "Missing credentials for provider {}. Run /login {}.",
418 next.model.provider, next.model.provider
419 ));
420 return;
421 }
422
423 let Ok(mut agent_guard) = self.agent.try_lock() else {
424 self.status_message = Some("Agent busy; try again".to_string());
425 return;
426 };
427 agent_guard.set_provider(provider_impl);
428 agent_guard
429 .stream_options_mut()
430 .api_key
431 .clone_from(&resolved_key_opt);
432 agent_guard
433 .stream_options_mut()
434 .headers
435 .clone_from(&next.headers);
436 drop(agent_guard);
437
438 let Ok(mut session_guard) = self.session.try_lock() else {
439 self.status_message = Some("Session busy; try again".to_string());
440 return;
441 };
442 session_guard.header.provider = Some(next.model.provider.clone());
443 session_guard.header.model_id = Some(next.model.id.clone());
444 session_guard.append_model_change(next.model.provider.clone(), next.model.id.clone());
445 drop(session_guard);
446 self.spawn_save_session();
447
448 self.model_entry = next.clone();
449 if let Ok(mut guard) = self.model_entry_shared.lock() {
450 *guard = next;
451 }
452 self.model = format!(
453 "{}/{}",
454 self.model_entry.model.provider, self.model_entry.model.id
455 );
456 self.status_message = Some(if fell_back_to_available {
457 format!(
458 "No scoped models matched; cycling all available models. Switched model: {}",
459 self.model
460 )
461 } else {
462 format!("Switched model: {}", self.model)
463 });
464 }
465
466 pub(super) fn quit_cmd(&mut self) -> Cmd {
467 if let Some(manager) = &self.extensions {
468 manager.clear_ui_sender();
469 }
470
471 let _ = self.event_tx.try_send(PiMsg::UiShutdown);
474 let (tx, _rx) = mpsc::channel::<PiMsg>(1);
475 drop(std::mem::replace(&mut self.event_tx, tx));
476 quit()
477 }
478
479 #[allow(clippy::too_many_lines)]
484 pub(super) fn handle_action(&mut self, action: AppAction, key: &KeyMsg) -> Option<Cmd> {
485 match action {
486 AppAction::Interrupt => {
490 if self.agent_state != AgentState::Idle {
492 self.last_escape_time = None;
493 let restored = self.restore_queued_messages_to_editor(true);
494 if restored > 0 {
495 self.status_message = Some(format!(
496 "Restored {restored} queued message{}",
497 if restored == 1 { "" } else { "s" }
498 ));
499 } else {
500 self.status_message = Some("Aborting request...".to_string());
501 }
502 return None;
503 }
504 if key.key_type == KeyType::Esc {
505 let (triggered, cmd) = self.handle_double_escape_action();
506 if triggered {
507 return cmd;
508 }
509 }
510 if key.key_type == KeyType::Esc && self.input_mode == InputMode::MultiLine {
512 self.input_mode = InputMode::SingleLine;
513 self.set_input_height(3);
514 self.status_message = Some("Single-line mode".to_string());
515 }
516 None
518 }
519 AppAction::Clear | AppAction::Copy => {
520 if self.agent_state != AgentState::Idle {
524 if let Some(handle) = &self.abort_handle {
525 handle.abort();
526 }
527 self.status_message = Some("Aborting request...".to_string());
528 return None;
529 }
530
531 let editor_text = self.input.value();
533 if !editor_text.is_empty() {
534 self.input.reset();
535 self.last_ctrlc_time = Some(std::time::Instant::now());
536 self.status_message = Some("Input cleared".to_string());
537 return None;
538 }
539
540 let now = std::time::Instant::now();
542 if let Some(last_time) = self.last_ctrlc_time {
543 if now.duration_since(last_time) < std::time::Duration::from_millis(500) {
545 return Some(self.quit_cmd());
546 }
547 }
548 self.last_ctrlc_time = Some(now);
550 self.status_message = Some("Press Ctrl+C again to quit".to_string());
551 None
552 }
553 AppAction::PasteImage => {
554 if let Some(path) = Self::paste_image_from_clipboard() {
555 self.insert_file_ref_path(&path);
556 self.status_message = Some("Image attached".to_string());
557 }
558 None
559 }
560 AppAction::Exit => {
561 if self.agent_state == AgentState::Idle && self.input.value().is_empty() {
563 return Some(self.quit_cmd());
564 }
565 None
567 }
568 AppAction::Suspend => {
569 #[cfg(unix)]
571 {
572 use std::process::Command;
573 let pid = std::process::id().to_string();
576 let _ = Command::new("kill").args(["-TSTP", &pid]).status();
577 self.status_message = Some("Resumed from background".to_string());
578 }
579 #[cfg(not(unix))]
580 {
581 self.status_message =
582 Some("Suspend not supported on this platform".to_string());
583 }
584 None
585 }
586 AppAction::ExternalEditor => {
587 if self.agent_state != AgentState::Idle {
589 self.status_message = Some("Cannot open editor while processing".to_string());
590 return None;
591 }
592 match self.open_external_editor() {
593 Ok(new_text) => {
594 self.input.set_value(&new_text);
595 self.status_message = Some("Editor content loaded".to_string());
596 }
597 Err(e) => {
598 self.status_message = Some(format!("Editor error: {e}"));
599 }
600 }
601 None
602 }
603 AppAction::Help => self.handle_slash_command(SlashCommand::Help, ""),
604 AppAction::OpenSettings => self.handle_slash_command(SlashCommand::Settings, ""),
605
606 AppAction::CycleModelForward => {
610 self.cycle_model(1);
611 None
612 }
613 AppAction::CycleModelBackward => {
614 self.cycle_model(-1);
615 None
616 }
617 AppAction::SelectModel => {
618 self.open_model_selector_configured_only();
619 None
620 }
621
622 AppAction::Submit => {
626 if self.agent_state != AgentState::Idle {
628 self.queue_input(QueuedMessageKind::Steering);
629 return None;
630 }
631 if self.input_mode == InputMode::MultiLine {
632 self.input.insert_rune('\n');
634 return None;
635 }
636 let value = self.input.value();
637 if !value.trim().is_empty() {
638 return self.submit_message(value.trim());
639 }
640 None
642 }
643 AppAction::FollowUp => {
644 if self.agent_state != AgentState::Idle {
647 self.queue_input(QueuedMessageKind::FollowUp);
648 return None;
649 }
650 let value = self.input.value();
651 if self.input_mode == InputMode::SingleLine && value.trim().is_empty() {
652 self.input_mode = InputMode::MultiLine;
653 self.set_input_height(6);
654 self.status_message = Some("Multi-line mode".to_string());
655 return None;
656 }
657 if !value.trim().is_empty() {
658 return self.submit_message(value.trim());
659 }
660 None
661 }
662 AppAction::NewLine => {
663 self.input.insert_rune('\n');
664 self.input_mode = InputMode::MultiLine;
665 self.set_input_height(6);
666 None
667 }
668
669 AppAction::CursorUp => {
673 if self.agent_state == AgentState::Idle && self.input_mode == InputMode::SingleLine
674 {
675 self.navigate_history_back();
676 }
677 None
679 }
680 AppAction::CursorDown => {
681 if self.agent_state == AgentState::Idle && self.input_mode == InputMode::SingleLine
682 {
683 self.navigate_history_forward();
684 }
685 None
686 }
687
688 AppAction::PageUp => {
692 let content = self.build_conversation_content();
695 let effective = self.view_effective_conversation_height().max(1);
696 self.conversation_viewport.height = effective;
697 self.conversation_viewport.set_content(content.trim_end());
698 self.conversation_viewport.page_up();
699 self.follow_stream_tail = false;
700 None
701 }
702 AppAction::PageDown => {
703 let content = self.build_conversation_content();
706 let effective = self.view_effective_conversation_height().max(1);
707 self.conversation_viewport.height = effective;
708 self.conversation_viewport.set_content(content.trim_end());
709 self.conversation_viewport.page_down();
710 if self.is_at_bottom() {
712 self.follow_stream_tail = true;
713 }
714 None
715 }
716
717 AppAction::Tab => {
721 if self.agent_state != AgentState::Idle || self.session_picker.is_some() {
722 return None;
723 }
724
725 let text = self.input.value();
726 if text.trim().is_empty() {
727 self.autocomplete.close();
728 return None;
729 }
730
731 let cursor = self.input.cursor_byte_offset();
732 let response = self.autocomplete.provider.suggest(&text, cursor);
733
734 if response.items.is_empty() {
735 self.autocomplete.close();
736 return None;
737 }
738
739 if response.items.len() == 1
740 && response
741 .items
742 .first()
743 .is_some_and(|item| item.kind == AutocompleteItemKind::Path)
744 {
745 let item = response.items[0].clone();
746 self.autocomplete.replace_range = response.replace;
747 self.accept_autocomplete(&item);
748 self.autocomplete.close();
749 return None;
750 }
751
752 self.autocomplete.open_with(response);
753 None
754 }
755
756 AppAction::Dequeue => {
760 let restored = self.restore_queued_messages_to_editor(false);
761 if restored == 0 {
762 self.status_message = Some("No queued messages to restore".to_string());
763 } else {
764 self.status_message = Some(format!(
765 "Restored {restored} queued message{}",
766 if restored == 1 { "" } else { "s" }
767 ));
768 }
769 None
770 }
771
772 AppAction::ToggleThinking => {
776 self.thinking_visible = !self.thinking_visible;
777 self.message_render_cache.invalidate_all();
778 let content = self.build_conversation_content();
779 let effective = self.view_effective_conversation_height().max(1);
780 self.conversation_viewport.height = effective;
781 self.conversation_viewport.set_content(content.trim_end());
782 self.status_message = Some(if self.thinking_visible {
783 "Thinking shown".to_string()
784 } else {
785 "Thinking hidden".to_string()
786 });
787 None
788 }
789 AppAction::ExpandTools => {
790 self.tools_expanded = !self.tools_expanded;
791 if self.tools_expanded {
795 for msg in &mut self.messages {
796 if msg.role == MessageRole::Tool {
797 msg.collapsed = false;
798 }
799 }
800 }
801 self.message_render_cache.invalidate_all();
802 let content = self.build_conversation_content();
803 let effective = self.view_effective_conversation_height().max(1);
804 self.conversation_viewport.height = effective;
805 self.conversation_viewport.set_content(content.trim_end());
806 self.status_message = Some(if self.tools_expanded {
807 "Tool output expanded".to_string()
808 } else {
809 "Tool output collapsed".to_string()
810 });
811 None
812 }
813
814 AppAction::BranchPicker => {
818 self.open_branch_picker();
819 None
820 }
821 AppAction::BranchNextSibling => {
822 self.cycle_sibling_branch(true);
823 None
824 }
825 AppAction::BranchPrevSibling => {
826 self.cycle_sibling_branch(false);
827 None
828 }
829
830 _ => {
834 None
837 }
838 }
839 }
840
841 pub(super) fn should_consume_action(&self, action: AppAction) -> bool {
846 match action {
847 AppAction::CursorUp | AppAction::CursorDown => {
850 self.agent_state == AgentState::Idle && self.input_mode == InputMode::SingleLine
851 }
852
853 AppAction::Exit => {
855 self.agent_state == AgentState::Idle && self.input.value().is_empty()
856 }
857
858 AppAction::PageUp
865 | AppAction::PageDown
866 | AppAction::CycleModelForward
867 | AppAction::CycleModelBackward
868 | AppAction::ToggleThinking
869 | AppAction::ExpandTools
870 | AppAction::FollowUp
871 | AppAction::NewLine
872 | AppAction::Submit
873 | AppAction::Dequeue
874 | AppAction::Interrupt
875 | AppAction::Clear
876 | AppAction::Copy
877 | AppAction::PasteImage
878 | AppAction::Suspend
879 | AppAction::ExternalEditor
880 | AppAction::Help
881 | AppAction::OpenSettings
882 | AppAction::Tab
883 | AppAction::BranchPicker
884 | AppAction::BranchNextSibling
885 | AppAction::BranchPrevSibling
886 | AppAction::SelectModel => true,
887
888 _ => false,
890 }
891 }
892}
893
894#[cfg(test)]
895mod tests {
896 use super::*;
897 use crate::agent::{Agent, AgentConfig};
898 use crate::config::Config;
899 use crate::model::{StreamEvent, Usage};
900 use crate::models::ModelEntry;
901 use crate::provider::{Context, InputType, Model, ModelCost, Provider, StreamOptions};
902 use crate::resources::{ResourceCliOptions, ResourceLoader};
903 use crate::session::Session;
904 use crate::tools::ToolRegistry;
905 use asupersync::channel::mpsc;
906 use asupersync::runtime::RuntimeBuilder;
907 use futures::stream;
908 use std::collections::HashMap;
909 use std::path::Path;
910 use std::pin::Pin;
911 use std::sync::Arc;
912 use std::sync::OnceLock;
913
914 struct DummyProvider;
915
916 #[async_trait::async_trait]
917 impl Provider for DummyProvider {
918 fn name(&self) -> &'static str {
919 "dummy"
920 }
921
922 fn api(&self) -> &'static str {
923 "dummy"
924 }
925
926 fn model_id(&self) -> &'static str {
927 "dummy-model"
928 }
929
930 async fn stream(
931 &self,
932 _context: &Context<'_>,
933 _options: &StreamOptions,
934 ) -> crate::error::Result<
935 Pin<Box<dyn futures::Stream<Item = crate::error::Result<StreamEvent>> + Send>>,
936 > {
937 Ok(Box::pin(stream::empty()))
938 }
939 }
940
941 fn runtime_handle() -> asupersync::runtime::RuntimeHandle {
942 static RT: OnceLock<asupersync::runtime::Runtime> = OnceLock::new();
943 RT.get_or_init(|| {
944 RuntimeBuilder::multi_thread()
945 .blocking_threads(1, 8)
946 .build()
947 .expect("build runtime")
948 })
949 .handle()
950 }
951
952 fn model_entry(
953 provider: &str,
954 id: &str,
955 api_key: Option<&str>,
956 headers: HashMap<String, String>,
957 ) -> ModelEntry {
958 ModelEntry {
959 model: Model {
960 id: id.to_string(),
961 name: id.to_string(),
962 api: "openai-completions".to_string(),
963 provider: provider.to_string(),
964 base_url: "https://example.invalid".to_string(),
965 reasoning: true,
966 input: vec![InputType::Text],
967 cost: ModelCost {
968 input: 0.0,
969 output: 0.0,
970 cache_read: 0.0,
971 cache_write: 0.0,
972 },
973 context_window: 128_000,
974 max_tokens: 8_192,
975 headers: HashMap::new(),
976 },
977 api_key: api_key.map(str::to_string),
978 headers,
979 auth_header: true,
980 compat: None,
981 oauth_config: None,
982 }
983 }
984
985 fn build_test_app(current: ModelEntry, available: Vec<ModelEntry>) -> PiApp {
986 let provider: Arc<dyn Provider> = Arc::new(DummyProvider);
987 let agent = Agent::new(
988 provider,
989 ToolRegistry::new(&[], Path::new("."), None),
990 AgentConfig::default(),
991 );
992 let session = Arc::new(asupersync::sync::Mutex::new(Session::in_memory()));
993 let resources = ResourceLoader::empty(false);
994 let resource_cli = ResourceCliOptions {
995 no_skills: false,
996 no_prompt_templates: false,
997 no_extensions: false,
998 no_themes: false,
999 skill_paths: Vec::new(),
1000 prompt_paths: Vec::new(),
1001 extension_paths: Vec::new(),
1002 theme_paths: Vec::new(),
1003 };
1004 let (event_tx, _event_rx) = mpsc::channel(64);
1005 PiApp::new(
1006 agent,
1007 session,
1008 Config::default(),
1009 resources,
1010 resource_cli,
1011 Path::new(".").to_path_buf(),
1012 current,
1013 Vec::new(),
1014 available,
1015 Vec::new(),
1016 event_tx,
1017 runtime_handle(),
1018 true,
1019 None,
1020 Some(KeyBindings::new()),
1021 Vec::new(),
1022 Usage::default(),
1023 )
1024 }
1025
1026 #[test]
1027 fn cycle_model_replaces_stream_options_api_key_and_headers() {
1028 let mut current_headers = HashMap::new();
1029 current_headers.insert("x-stale".to_string(), "old".to_string());
1030 let current = model_entry("openai", "gpt-4o-mini", Some("old-key"), current_headers);
1031
1032 let mut next_headers = HashMap::new();
1033 next_headers.insert("x-provider-header".to_string(), "next".to_string());
1034 let next = model_entry(
1035 "openrouter",
1036 "openai/gpt-4o-mini",
1037 Some("next-key"),
1038 next_headers,
1039 );
1040
1041 let mut app = build_test_app(current.clone(), vec![current, next]);
1042 {
1043 let mut guard = app.agent.try_lock().expect("agent lock");
1044 guard.stream_options_mut().api_key = Some("stale-key".to_string());
1045 guard
1046 .stream_options_mut()
1047 .headers
1048 .insert("x-stale".to_string(), "stale".to_string());
1049 }
1050
1051 app.cycle_model(1);
1052
1053 let mut guard = app.agent.try_lock().expect("agent lock");
1054 assert_eq!(
1055 guard.stream_options_mut().api_key.as_deref(),
1056 Some("next-key")
1057 );
1058 assert_eq!(
1059 guard
1060 .stream_options_mut()
1061 .headers
1062 .get("x-provider-header")
1063 .map(String::as_str),
1064 Some("next")
1065 );
1066 assert!(
1067 !guard.stream_options_mut().headers.contains_key("x-stale"),
1068 "cycling models must replace stale provider headers"
1069 );
1070 }
1071
1072 #[test]
1073 fn cycle_model_clears_stale_api_key_when_next_model_has_no_key() {
1074 let current = model_entry("openai", "gpt-4o-mini", Some("old-key"), HashMap::new());
1075 let mut next = model_entry("ollama", "llama3.2", None, HashMap::new());
1076 next.auth_header = false;
1077 let mut app = build_test_app(current.clone(), vec![current, next]);
1078 {
1079 let mut guard = app.agent.try_lock().expect("agent lock");
1080 guard.stream_options_mut().api_key = Some("stale-key".to_string());
1081 guard
1082 .stream_options_mut()
1083 .headers
1084 .insert("x-stale".to_string(), "stale".to_string());
1085 }
1086
1087 app.cycle_model(1);
1088
1089 let mut guard = app.agent.try_lock().expect("agent lock");
1090 assert!(
1091 guard.stream_options_mut().api_key.is_none(),
1092 "cycling to a keyless model must clear stale API key"
1093 );
1094 assert!(
1095 guard.stream_options_mut().headers.is_empty(),
1096 "cycling to keyless model with no headers must clear stale headers"
1097 );
1098 }
1099
1100 #[test]
1101 fn slash_model_allows_switch_to_keyless_provider_without_api_key() {
1102 let current = model_entry("openai", "gpt-4o-mini", Some("old-key"), HashMap::new());
1103 let mut keyless = model_entry("ollama", "llama3.2", None, HashMap::new());
1104 keyless.auth_header = false;
1105 let mut app = build_test_app(current.clone(), vec![current, keyless]);
1106
1107 let _ = app.handle_slash_command(SlashCommand::Model, "ollama/llama3.2");
1108
1109 assert_eq!(app.model, "ollama/llama3.2");
1110 let mut guard = app.agent.try_lock().expect("agent lock");
1111 assert!(
1112 guard.stream_options_mut().api_key.is_none(),
1113 "keyless model switch must not keep stale API key"
1114 );
1115 }
1116
1117 #[test]
1118 fn slash_model_rejects_missing_credentials_for_required_provider() {
1119 let current = model_entry("openai", "gpt-4o-mini", Some("old-key"), HashMap::new());
1120 let mut requires_creds = model_entry("acme-remote", "cloud-model", None, HashMap::new());
1121 requires_creds.auth_header = true;
1122 let mut app = build_test_app(current.clone(), vec![current, requires_creds]);
1123
1124 let _ = app.handle_slash_command(SlashCommand::Model, "acme-remote/cloud-model");
1125
1126 assert_eq!(app.model, "openai/gpt-4o-mini");
1127 assert!(
1128 app.status_message
1129 .as_deref()
1130 .is_some_and(|msg| msg.contains("Missing credentials for provider acme-remote")),
1131 "switch should fail fast when selected provider still lacks credentials"
1132 );
1133 }
1134
1135 #[test]
1136 fn slash_model_treats_blank_inline_key_as_missing_credentials() {
1137 let current = model_entry("openai", "gpt-4o-mini", Some("old-key"), HashMap::new());
1138 let mut blank_key = model_entry("acme-remote", "cloud-model", Some(" "), HashMap::new());
1139 blank_key.auth_header = true;
1140 let mut app = build_test_app(current.clone(), vec![current, blank_key]);
1141
1142 let _ = app.handle_slash_command(SlashCommand::Model, "acme-remote/cloud-model");
1143
1144 assert_eq!(app.model, "openai/gpt-4o-mini");
1145 assert!(
1146 app.status_message
1147 .as_deref()
1148 .is_some_and(|msg| msg.contains("Missing credentials for provider acme-remote")),
1149 "blank inline keys must not bypass credential checks"
1150 );
1151 }
1152}