Skip to main content

pi/interactive/
keybindings.rs

1use super::commands::model_entry_matches;
2use super::*;
3
4impl PiApp {
5    /// Format keyboard shortcuts for /hotkeys display.
6    ///
7    /// Groups actions by category and shows their key bindings.
8    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            // Skip empty categories
27            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                // Format bindings as comma-separated list
40                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        // Some bindings are ambiguous and depend on UI state.
58        // Example: `ctrl+d` can mean "delete forward" while editing, but "exit" when the editor
59        // is empty (legacy behavior).
60        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            // Navigate between buttons.
75            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            // Confirm selection.
81            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                // Record persistent decisions for "Always" choices.
89                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            // Escape = deny once.
103            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    /// Open external editor with current input text.
235    ///
236    /// Uses $VISUAL if set, otherwise $EDITOR, otherwise "vi".
237    /// Supports editors with arguments like "code --wait" or "vim -u NONE".
238    pub(super) fn open_external_editor(&self) -> std::io::Result<String> {
239        use std::io::Write;
240
241        // Determine editor command
242        let editor = std::env::var("VISUAL")
243            .or_else(|_| std::env::var("EDITOR"))
244            .unwrap_or_else(|_| "vi".to_string());
245
246        // Create temp file with current editor content
247        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        // Spawn editor via shell to handle EDITOR with arguments (e.g., "code --wait")
255        // The shell properly handles quoting, arguments, and PATH lookup
256        #[cfg(unix)]
257        let status = std::process::Command::new("sh")
258            .args(["-c", &format!("{editor} \"$1\"")])
259            .arg("--") // separator for positional args
260            .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        // Read back the edited content
275        let new_text = std::fs::read_to_string(&temp_path)?;
276        Ok(new_text)
277    }
278
279    /// Navigate to previous history entry.
280    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    /// Navigate to next history entry.
290    fn navigate_history_forward(&mut self) {
291        // Avoid clearing the editor when the user hasn't entered history navigation.
292        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        // Drop the async → bubbletea bridge sender so bubbletea can shut down cleanly.
472        // Without this, bubbletea's external forwarder thread can block on `recv()` during quit.
473        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    /// Handle an action dispatched from the keybindings layer.
480    ///
481    /// Returns `Some(Cmd)` if a command should be executed,
482    /// `None` if the action was handled without a command.
483    #[allow(clippy::too_many_lines)]
484    pub(super) fn handle_action(&mut self, action: AppAction, key: &KeyMsg) -> Option<Cmd> {
485        match action {
486            // =========================================================
487            // Application actions
488            // =========================================================
489            AppAction::Interrupt => {
490                // Escape: Abort if processing, otherwise context-dependent
491                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                // When idle, Escape exits multi-line mode (but does NOT quit)
511                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                // Legacy behavior: Escape when idle does nothing (no quit)
517                None
518            }
519            AppAction::Clear | AppAction::Copy => {
520                // Ctrl+C: abort if processing, clear editor if has text, or quit on double-tap
521                // Note: Copy and Clear both bound to Ctrl+C - Copy takes precedence in lookup
522                // When selection is implemented, Copy should only trigger with active selection
523                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                // If editor has text, clear it
532                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                // Editor is empty - check for double-tap to quit
541                let now = std::time::Instant::now();
542                if let Some(last_time) = self.last_ctrlc_time {
543                    // Double-tap within 500ms quits
544                    if now.duration_since(last_time) < std::time::Duration::from_millis(500) {
545                        return Some(self.quit_cmd());
546                    }
547                }
548                // Record this Ctrl+C and show hint
549                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                // Ctrl+D: Exit only when editor is empty (legacy behavior)
562                if self.agent_state == AgentState::Idle && self.input.value().is_empty() {
563                    return Some(self.quit_cmd());
564                }
565                // Editor has text - don't consume, let TextArea handle as delete char forward
566                None
567            }
568            AppAction::Suspend => {
569                // Ctrl+Z: Suspend to background (Unix only)
570                #[cfg(unix)]
571                {
572                    use std::process::Command;
573                    // Send SIGTSTP to our process. When resumed via `fg`, status() returns
574                    // and we show the resumed message.
575                    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                // Ctrl+G: Open external editor with current input
588                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            // =========================================================
607            // Models & thinking
608            // =========================================================
609            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            // =========================================================
623            // Text input actions
624            // =========================================================
625            AppAction::Submit => {
626                // Enter: Submit when idle, queue steering when busy
627                if self.agent_state != AgentState::Idle {
628                    self.queue_input(QueuedMessageKind::Steering);
629                    return None;
630                }
631                if self.input_mode == InputMode::MultiLine {
632                    // In multi-line mode, Enter inserts a newline (Alt+Enter submits).
633                    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                // Don't consume - let TextArea handle Enter if needed
641                None
642            }
643            AppAction::FollowUp => {
644                // Alt+Enter: queue follow-up when busy. When idle, toggles multi-line mode if the
645                // editor is empty; otherwise it submits like Enter.
646                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            // =========================================================
670            // Cursor movement (history navigation in single-line mode)
671            // =========================================================
672            AppAction::CursorUp => {
673                if self.agent_state == AgentState::Idle && self.input_mode == InputMode::SingleLine
674                {
675                    self.navigate_history_back();
676                }
677                // In multi-line mode, let TextArea handle cursor movement
678                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            // =========================================================
689            // Viewport scrolling
690            // =========================================================
691            AppAction::PageUp => {
692                // Sync viewport content and height so page_up() has correct
693                // line count and page size.
694                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                // Sync viewport content and height so page_down() has correct
704                // line count and page size.
705                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                // Re-enable auto-follow if the user scrolled back to the bottom.
711                if self.is_at_bottom() {
712                    self.follow_stream_tail = true;
713                }
714                None
715            }
716
717            // =========================================================
718            // Autocomplete
719            // =========================================================
720            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            // =========================================================
757            // Message queue actions
758            // =========================================================
759            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            // =========================================================
773            // Display actions
774            // =========================================================
775            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                // When expanding globally, also reset per-message collapse for
792                // all tools so they show expanded. When collapsing globally,
793                // the global flag is enough (render checks both).
794                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            // =========================================================
815            // Branch navigation
816            // =========================================================
817            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            // =========================================================
831            // Actions not yet implemented - let through to component
832            // =========================================================
833            _ => {
834                // Many actions (editor operations, model cycling, etc.) will be
835                // implemented in future PRs. For now, don't consume them.
836                None
837            }
838        }
839    }
840
841    /// Determine if an action should be consumed (not forwarded to TextArea).
842    ///
843    /// Some actions need to be consumed even when `handle_action` returns `None`,
844    /// to prevent the TextArea from also handling the key.
845    pub(super) fn should_consume_action(&self, action: AppAction) -> bool {
846        match action {
847            // History navigation and Submit consume in single-line mode (otherwise TextArea
848            // handles arrow keys or inserts a newline on Enter)
849            AppAction::CursorUp | AppAction::CursorDown => {
850                self.agent_state == AgentState::Idle && self.input_mode == InputMode::SingleLine
851            }
852
853            // Exit (Ctrl+D) only consumed when editor is empty (otherwise deleteCharForward)
854            AppAction::Exit => {
855                self.agent_state == AgentState::Idle && self.input.value().is_empty()
856            }
857
858            // Viewport scrolling should always be consumed.
859            // FollowUp (Alt+Enter) should be consumed so TextArea doesn't insert text.
860            // NewLine is handled directly (Shift+Enter / Ctrl+Enter).
861            // Interrupt/Clear/Copy are always consumed.
862            // Suspend/ExternalEditor are always consumed.
863            // Tab is consumed (autocomplete).
864            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            // Other actions pass through to TextArea
889            _ => 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}