Skip to main content

rab/agent/ui/
chat_editor.rs

1use std::collections::HashMap;
2
3use crossterm::event::KeyEvent;
4
5use crate::agent::ui::theme::ThemeKey;
6use crate::tui::Component;
7use crate::tui::Theme;
8use crate::tui::autocomplete::{CombinedAutocompleteProvider, SlashCommand};
9use crate::tui::components::Editor;
10use crate::tui::components::editor::EditorOptions;
11use crate::tui::keybindings::{
12    ACTION_APP_CLEAR, ACTION_APP_COMPACT_TOGGLE, ACTION_APP_EDITOR_EXTERNAL, ACTION_APP_ESCAPE,
13    ACTION_APP_EXIT, ACTION_APP_HELP, ACTION_APP_MESSAGE_DEQUEUE, ACTION_APP_MESSAGE_FOLLOW_UP,
14    ACTION_APP_MODEL_CYCLE_BACKWARD, ACTION_APP_MODEL_CYCLE_FORWARD, ACTION_APP_MODEL_SELECTOR,
15    ACTION_APP_THINKING_CYCLE, ACTION_APP_TOGGLE_THINKING, ACTION_APP_TOOLS_EXPAND,
16    ACTION_INPUT_SUBMIT, ACTION_SELECT_CANCEL, get_keybindings,
17};
18
19/// Actions that ChatEditor can signal to the app layer.
20/// Mirrors pi's CustomEditor approach: the editor handles its own text keys
21/// and returns an action for app-level keybindings.
22#[derive(Debug)]
23pub enum InputAction {
24    /// Key was consumed by the editor (text editing, navigation, etc.)
25    Handled,
26    /// Escape pressed (app should abort streaming or close autocomplete)
27    Escape,
28    /// Ctrl+C pressed (app should clear editor, or double-press to exit)
29    Clear,
30    /// Ctrl+D pressed while editor is empty (app should quit)
31    Exit,
32    /// Shift+Tab pressed (app should cycle thinking level)
33    ThinkingCycle,
34    /// Ctrl+L pressed (app should open model selector)
35    ModelSelector,
36    /// Ctrl+P pressed (app should cycle to next model)
37    ModelCycleForward,
38    /// Shift+Ctrl+P pressed (app should cycle to previous model)
39    ModelCycleBackward,
40    /// Ctrl+T pressed (app should toggle thinking visibility)
41    ToggleThinking,
42    /// Ctrl+O pressed (app should toggle all tool output expansion)
43    ToolsExpand,
44    /// Ctrl+G pressed (app should open external editor)
45    EditorExternal,
46    /// F1 pressed (app should show help overlay)
47    Help,
48    /// Enter pressed with text (app should submit the message)
49    Submit(String),
50    /// Alt+Enter pressed (app should queue follow-up message)
51    FollowUp(String),
52    /// Alt+Up pressed (app should restore queued message back to editor)
53    Dequeue,
54    /// Ctrl+Shift+C pressed (app should toggle auto-compact)
55    CompactToggle,
56}
57
58/// Rab-specific chat editor that wraps the core tui::Editor.
59///
60/// Mirrors pi's CustomEditor pattern: ChatEditor handles keyboard input and
61/// dispatches app-level actions (escape, submit, model selector, etc.) as
62/// an InputAction enum, while text-editing keys are delegated to the inner
63/// Editor. The app layer matches on InputAction to perform side effects.
64///
65/// Key differences from pi's CustomEditor:
66/// - Text-editing keys (Ctrl+Z undo, Ctrl+J newline, Up/Down history, Tab,
67///   PageUp/PageDown, etc.) are delegated entirely to the inner Editor,
68///   matching pi's editor-centric handling.
69/// - Only app-level keybindings (interrupt, exit, model selector, help, etc.)
70///   are intercepted here.
71#[allow(clippy::type_complexity)]
72pub struct ChatEditor {
73    pub editor: Editor,
74    /// Working directory for file path completion in autocomplete provider.
75    cwd: std::path::PathBuf,
76    /// Slash commands for the autocomplete provider.
77    slash_commands: Vec<SlashCommand>,
78    /// Extension-registered shortcuts (pi-style). Checked before built-in handling.
79    on_extension_shortcut: Option<Box<dyn FnMut(&KeyEvent) -> bool + Send>>,
80    /// Dynamically registered app action handlers (pi-style).
81    action_handlers: HashMap<String, Box<dyn FnMut() + Send>>,
82}
83
84impl ChatEditor {
85    pub fn new(_theme: &dyn Theme, cwd: std::path::PathBuf) -> Self {
86        let editor = Editor::new(EditorOptions { padding_x: 0 });
87
88        Self {
89            editor,
90            cwd,
91            slash_commands: Vec::new(),
92            on_extension_shortcut: None,
93            action_handlers: HashMap::new(),
94        }
95    }
96
97    /// Build and set the autocomplete provider from the current slash commands and cwd.
98    fn rebuild_autocomplete_provider(&mut self) {
99        let provider = CombinedAutocompleteProvider::new(
100            self.slash_commands.clone(),
101            self.cwd.to_string_lossy().to_string(),
102        );
103        self.editor.set_autocomplete_provider(Box::new(provider));
104    }
105
106    /// Set the available slash commands for autocomplete.
107    pub fn set_slash_commands(&mut self, commands: Vec<SlashCommand>) {
108        self.slash_commands = commands;
109        self.rebuild_autocomplete_provider();
110    }
111
112    /// Set a handler for extension-registered shortcuts (pi-style).
113    /// The handler receives the key event and returns true if the key was handled.
114    pub fn set_extension_shortcut_handler(
115        &mut self,
116        handler: Box<dyn FnMut(&KeyEvent) -> bool + Send>,
117    ) {
118        self.on_extension_shortcut = Some(handler);
119    }
120
121    /// Register a dynamic app action handler (pi-style).
122    /// When the keybinding for `action` is pressed, `handler` is called.
123    /// Does NOT override built-in actions (Escape, Ctrl+C, Ctrl+D, Enter).
124    pub fn on_action(&mut self, action: &str, handler: Box<dyn FnMut() + Send>) {
125        self.action_handlers.insert(action.to_string(), handler);
126    }
127
128    /// After programmatic set_text, trigger autocomplete check (pi-style).
129    pub fn check_autocomplete(&mut self) {
130        // The inner Editor's autocomplete provider auto-triggers on typing,
131        // but after set_text we force a check so slash commands show immediately.
132        if !self.editor.autocomplete_active {
133            self.editor.try_trigger_autocomplete();
134        }
135    }
136
137    /// Update the working directory.
138    pub fn set_cwd(&mut self, cwd: std::path::PathBuf) {
139        self.cwd = cwd;
140        self.rebuild_autocomplete_provider();
141    }
142
143    /// Handle keyboard input. Mirrors pi's CustomEditor.handleInput:
144    ///
145    /// 1. Checks app-level keys (escape, clear, submit, model selector, etc.)
146    ///    and returns the corresponding InputAction for the app layer to handle.
147    /// 2. All other keys are delegated to the inner Editor for text editing,
148    ///    including Ctrl+Z (undo), Ctrl+J (newline), Up/Down (history),
149    ///    Tab (autocomplete), PageUp/PageDown (scroll), etc.
150    ///
151    /// This keeps app-level side effects (aborting agent, opening overlays, etc.)
152    /// in the app layer while keeping text-editing logic in the Editor component.
153    /// Update editor border color based on thinking level or bash mode.
154    /// Matches pi's `updateEditorBorderColor()`.
155    /// - Bash mode (text starts with `!`): uses `bashMode` color
156    /// - Otherwise: uses thinking level color (`thinkingOff`..`thinkingXhigh`)
157    pub fn update_border_color(
158        &mut self,
159        thinking_level: Option<&str>,
160        theme: &dyn crate::tui::Theme,
161    ) {
162        let text = self.editor.get_text();
163        if text.trim_start().starts_with('!') {
164            let ansi = theme.fg_key(ThemeKey::BashMode, "").to_string();
165            // Extract just the ANSI prefix (before any text).
166            // Use find('m') to get only the color-set code, not the trailing reset.
167            // theme.fg() returns "\x1b[...m\x1b[39m"; we want "\x1b[...m" only.
168            let prefix = if ansi.starts_with('\x1b') {
169                let end = ansi.find('m').unwrap_or(ansi.len());
170                ansi[..end + 1].to_string()
171            } else {
172                ansi
173            };
174            self.editor.border_color = crate::tui::Style::new().fg(prefix);
175        } else {
176            let level = thinking_level.unwrap_or("off");
177            let color_name = match level {
178                "off" => "thinkingOff",
179                "minimal" => "thinkingMinimal",
180                "low" => "thinkingLow",
181                "medium" => "thinkingMedium",
182                "high" => "thinkingHigh",
183                "xhigh" | "max" => "thinkingXhigh",
184                _ => "thinkingOff",
185            };
186            let ansi = theme.fg(color_name, "").to_string();
187            let prefix = if ansi.starts_with('\x1b') {
188                let end = ansi.find('m').unwrap_or(ansi.len());
189                ansi[..end + 1].to_string()
190            } else {
191                ansi
192            };
193            self.editor.border_color = crate::tui::Style::new().fg(prefix);
194        }
195    }
196
197    pub fn handle_input(&mut self, key: &KeyEvent) -> InputAction {
198        let kb = get_keybindings();
199
200        // ═══════════════════════════════════════════════════════════════════
201        // 1. Extension shortcuts (pi-style: checked before built-in handling)
202        // ═══════════════════════════════════════════════════════════════════
203        if let Some(ref mut handler) = self.on_extension_shortcut
204            && handler(key)
205        {
206            return InputAction::Handled;
207        }
208
209        // ═══════════════════════════════════════════════════════════════════
210        // 2. Built-in app-level actions (hardcoded, matching pi's CustomEditor)
211        // ═══════════════════════════════════════════════════════════════════
212
213        // ── Escape: close autocomplete first if active, else signal app ──
214        // Mirrors pi: if autocomplete is active, let Editor handle it (cancels autocomplete).
215        if kb.matches(key, ACTION_SELECT_CANCEL) || kb.matches(key, ACTION_APP_ESCAPE) {
216            if self.editor.autocomplete_active {
217                self.editor.handle_input(key);
218                return InputAction::Handled;
219            }
220            return InputAction::Escape;
221        }
222
223        // ── Ctrl+C: clear (abort streaming or clear editor) ──
224        if kb.matches(key, ACTION_APP_CLEAR) {
225            return InputAction::Clear;
226        }
227
228        // ── Ctrl+D: exit when editor is empty, else let Editor handle as delete-forward ──
229        if kb.matches(key, ACTION_APP_EXIT) {
230            if self.editor.get_text().is_empty() {
231                return InputAction::Exit;
232            }
233            // Fall through so the Editor handles Ctrl+D as deleteCharForward
234            self.editor.handle_input(key);
235            return InputAction::Handled;
236        }
237
238        // ── Shift+Tab: cycle thinking level ──
239        if kb.matches(key, ACTION_APP_THINKING_CYCLE) {
240            return InputAction::ThinkingCycle;
241        }
242
243        // ── Ctrl+L: model selector ──
244        if kb.matches(key, ACTION_APP_MODEL_SELECTOR) {
245            return InputAction::ModelSelector;
246        }
247
248        // ── Ctrl+P: cycle model forward ──
249        if kb.matches(key, ACTION_APP_MODEL_CYCLE_FORWARD) {
250            return InputAction::ModelCycleForward;
251        }
252
253        // ── Shift+Ctrl+P: cycle model backward ──
254        if kb.matches(key, ACTION_APP_MODEL_CYCLE_BACKWARD) {
255            return InputAction::ModelCycleBackward;
256        }
257
258        // ── Ctrl+T: toggle thinking visibility ──
259        if kb.matches(key, ACTION_APP_TOGGLE_THINKING) {
260            return InputAction::ToggleThinking;
261        }
262
263        // ── Ctrl+O: toggle all tool output expansion ──
264        if kb.matches(key, ACTION_APP_TOOLS_EXPAND) {
265            return InputAction::ToolsExpand;
266        }
267
268        // ── Ctrl+G: external editor ──
269        if kb.matches(key, ACTION_APP_EDITOR_EXTERNAL) {
270            return InputAction::EditorExternal;
271        }
272
273        // ── F1: help overlay ──
274        if kb.matches(key, ACTION_APP_HELP) {
275            return InputAction::Help;
276        }
277
278        // ── Alt+Enter: queue follow-up message ──
279        if kb.matches(key, ACTION_APP_MESSAGE_FOLLOW_UP) {
280            let text = self.editor.get_text();
281            if !text.trim().is_empty() {
282                self.editor.add_to_history(&text);
283                self.editor.set_text("");
284                return InputAction::FollowUp(text);
285            }
286            return InputAction::Handled;
287        }
288
289        // ── Alt+Up: restore queued message back to editor (dequeue) ──
290        if kb.matches(key, ACTION_APP_MESSAGE_DEQUEUE) {
291            return InputAction::Dequeue;
292        }
293
294        // ── Ctrl+Shift+C: toggle auto-compact ──
295        if kb.matches(key, ACTION_APP_COMPACT_TOGGLE) {
296            return InputAction::CompactToggle;
297        }
298
299        // ═══════════════════════════════════════════════════════════════════
300        // 3. Dynamically registered app actions (pi-style actionHandlers)
301        // ═══════════════════════════════════════════════════════════════════
302        // Matches pi: checked after built-ins, before Editor delegation.
303        // Excludes app.interrupt and app.exit which are handled above.
304        for (action, handler) in &mut self.action_handlers {
305            if action != "app.interrupt" && action != "app.exit" && kb.matches(key, action) {
306                handler();
307                return InputAction::Handled;
308            }
309        }
310
311        // ═══════════════════════════════════════════════════════════════════
312        // 4. Enter: let Editor handle submit (pi-style)
313        // ═══════════════════════════════════════════════════════════════════
314        // The Editor's handle_input processes Enter via submit(), which:
315        //   1. Expands paste markers
316        //   2. Stores the text in last_submitted_text
317        //   3. Clears editor state (pastes, undo, history browsing)
318        //   4. Calls on_submit callback
319        //   5. Sets just_submitted flag
320        //
321        // We check just_submitted after handle_input to detect submission.
322        // For slash command completion via Enter, the completion is applied
323        // inside handle_input before submit() is called, so last_submitted_text
324        // contains the completed command text.
325        if kb.matches(key, ACTION_INPUT_SUBMIT) {
326            self.editor.just_submitted = false;
327            self.editor.handle_input(key);
328            if self.editor.just_submitted {
329                // Editor processed the submit - use last_submitted_text (captured
330                // before clearing) so slash command completions are included.
331                let text = self.editor.last_submitted_text.clone();
332                let has_content = !text.trim().is_empty();
333                if has_content {
334                    self.editor.add_to_history(&text);
335                }
336                return InputAction::Submit(text);
337            }
338            return InputAction::Handled;
339        }
340
341        // ═══════════════════════════════════════════════════════════════════
342        // 5. All other keys: delegate to the core Editor for text editing
343        // ═══════════════════════════════════════════════════════════════════
344        // This includes:
345        //   - Ctrl+Z → undo (ACTION_EDITOR_UNDO)
346        //   - Ctrl+J → newline (ACTION_INPUT_NEW_LINE)
347        //   - Up/Down → cursor + history (ACTION_EDITOR_CURSOR_UP/DOWN)
348        //   - Tab → autocomplete (ACTION_INPUT_TAB)
349        //   - PageUp/PageDown → scroll (ACTION_EDITOR_PAGE_UP/DOWN)
350        //   - All printable chars, movement, deletion, kill/yank, etc.
351        self.editor.just_submitted = false;
352        self.editor.handle_input(key);
353
354        InputAction::Handled
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use crate::tui::autocomplete::SlashCommand;
362    use crate::tui::theme::NoopTheme;
363    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
364    fn make_editor() -> ChatEditor {
365        ChatEditor::new(&NoopTheme, std::env::temp_dir())
366    }
367
368    fn char_key(c: char) -> KeyEvent {
369        KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
370    }
371
372    fn ctrl(c: char) -> KeyEvent {
373        KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
374    }
375
376    fn alt_key(code: KeyCode) -> KeyEvent {
377        KeyEvent::new(code, KeyModifiers::ALT)
378    }
379
380    fn ctrl_shift(c: char) -> KeyEvent {
381        KeyEvent::new(
382            KeyCode::Char(c),
383            KeyModifiers::CONTROL | KeyModifiers::SHIFT,
384        )
385    }
386
387    fn enter() -> KeyEvent {
388        KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
389    }
390
391    fn escape() -> KeyEvent {
392        KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)
393    }
394
395    fn up() -> KeyEvent {
396        KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)
397    }
398
399    fn page_up() -> KeyEvent {
400        KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE)
401    }
402
403    fn page_down() -> KeyEvent {
404        KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE)
405    }
406
407    fn f1() -> KeyEvent {
408        KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE)
409    }
410
411    fn shift_tab() -> KeyEvent {
412        KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE)
413    }
414
415    // ── App-level key tests ──
416
417    #[test]
418    fn test_escape_closes_autocomplete() {
419        let mut ed = make_editor();
420        ed.set_slash_commands(vec![SlashCommand {
421            name: "help".into(),
422            description: None,
423            argument_hint: None,
424            argument_completions: None,
425            get_argument_completions: None,
426        }]);
427        ed.editor.set_text("/");
428        // Trigger autocomplete via the provider (Tab is handled by Editor now)
429        ed.editor.handle_input(&ctrl('l')); // not helpful, just press a key
430        // Manually trigger autocomplete by typing a letter
431        ed.editor.set_text("/h");
432        // The inner Editor should have triggered autocomplete via the provider
433        // when /h was typed and the auto-trigger ran on 'h'
434        // But try_trigger_autocomplete is called from insert_character -> check_autocomplete_trigger
435        // which only fires on certain chars. Let's just set autocomplete directly.
436        let suggestions = vec![crate::tui::components::select_list::SelectItem::new(
437            "help", "help",
438        )];
439        ed.editor.set_autocomplete(suggestions);
440        assert!(
441            ed.editor.autocomplete_active,
442            "autocomplete should be active"
443        );
444
445        // Escape should close it - now handled by Editor (fallthrough)
446        let _action = ed.handle_input(&escape());
447        assert!(matches!(_action, InputAction::Handled));
448        assert!(!ed.editor.autocomplete_active, "autocomplete should close");
449    }
450
451    #[test]
452    fn test_escape_no_autocomplete_returns_action() {
453        let mut ed = make_editor();
454        assert!(!ed.editor.autocomplete_active);
455        let action = ed.handle_input(&escape());
456        assert!(matches!(action, InputAction::Escape));
457    }
458
459    #[test]
460    fn test_ctrl_c_returns_clear() {
461        let mut ed = make_editor();
462        let action = ed.handle_input(&ctrl('c'));
463        assert!(matches!(action, InputAction::Clear));
464    }
465
466    #[test]
467    fn test_ctrl_d_empty_returns_exit() {
468        let mut ed = make_editor();
469        assert!(ed.editor.get_text().is_empty());
470        let action = ed.handle_input(&ctrl('d'));
471        assert!(matches!(action, InputAction::Exit));
472    }
473
474    #[test]
475    fn test_ctrl_d_with_text_deletes_forward() {
476        let mut ed = make_editor();
477        ed.editor.set_text("hello");
478        // Cursor is at end by default, move to start
479        for _ in 0..5 {
480            ed.editor
481                .handle_input(&KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
482        }
483        assert_eq!(ed.editor.get_cursor(), (0, 0));
484        let action = ed.handle_input(&ctrl('d'));
485        // Should be Handled (Editor handles Ctrl+D as delete_forward)
486        assert!(matches!(action, InputAction::Handled));
487        assert_eq!(ed.editor.get_text(), "ello");
488    }
489
490    #[test]
491    fn test_shift_tab_returns_thinking_cycle() {
492        let mut ed = make_editor();
493        let action = ed.handle_input(&shift_tab());
494        assert!(matches!(action, InputAction::ThinkingCycle));
495    }
496
497    #[test]
498    fn test_ctrl_l_returns_model_selector() {
499        let mut ed = make_editor();
500        let action = ed.handle_input(&ctrl('l'));
501        assert!(matches!(action, InputAction::ModelSelector));
502    }
503
504    #[test]
505    fn test_ctrl_p_returns_model_cycle_forward() {
506        let mut ed = make_editor();
507        let action = ed.handle_input(&ctrl('p'));
508        assert!(matches!(action, InputAction::ModelCycleForward));
509    }
510
511    #[test]
512    fn test_ctrl_shift_p_returns_model_cycle_backward() {
513        let mut ed = make_editor();
514        let action = ed.handle_input(&ctrl_shift('p'));
515        assert!(matches!(action, InputAction::ModelCycleBackward));
516    }
517
518    #[test]
519    fn test_ctrl_t_returns_toggle_thinking() {
520        let mut ed = make_editor();
521        let action = ed.handle_input(&ctrl('t'));
522        assert!(matches!(action, InputAction::ToggleThinking));
523    }
524
525    #[test]
526    fn test_ctrl_o_returns_tools_expand() {
527        let mut ed = make_editor();
528        let action = ed.handle_input(&ctrl('o'));
529        assert!(matches!(action, InputAction::ToolsExpand));
530    }
531
532    #[test]
533    fn test_ctrl_g_returns_editor_external() {
534        let mut ed = make_editor();
535        let action = ed.handle_input(&ctrl('g'));
536        assert!(matches!(action, InputAction::EditorExternal));
537    }
538
539    #[test]
540    fn test_f1_returns_help() {
541        let mut ed = make_editor();
542        let action = ed.handle_input(&f1());
543        assert!(matches!(action, InputAction::Help));
544    }
545
546    #[test]
547    fn test_alt_enter_queues_follow_up() {
548        let mut ed = make_editor();
549        ed.editor.set_text("follow up text");
550        let action = ed.handle_input(&alt_key(KeyCode::Enter));
551        match action {
552            InputAction::FollowUp(text) => {
553                assert_eq!(text, "follow up text");
554            }
555            other => panic!("Expected FollowUp, got {:?}", other),
556        }
557        assert!(
558            ed.editor.get_text().is_empty(),
559            "editor should clear on follow-up"
560        );
561    }
562
563    #[test]
564    fn test_alt_enter_empty_returns_handled() {
565        let mut ed = make_editor();
566        let action = ed.handle_input(&alt_key(KeyCode::Enter));
567        assert!(matches!(action, InputAction::Handled));
568    }
569
570    #[test]
571    fn test_ctrl_shift_c_returns_compact_toggle() {
572        let mut ed = make_editor();
573        let action = ed.handle_input(&ctrl_shift('c'));
574        assert!(matches!(action, InputAction::CompactToggle));
575    }
576
577    #[test]
578    fn test_alt_up_returns_dequeue() {
579        let mut ed = make_editor();
580        let action = ed.handle_input(&alt_key(KeyCode::Up));
581        assert!(matches!(action, InputAction::Dequeue));
582    }
583
584    #[test]
585    fn test_enter_with_text_submits_and_clears() {
586        let mut ed = make_editor();
587        ed.editor.set_text("hello world");
588        let action = ed.handle_input(&enter());
589        match action {
590            InputAction::Submit(text) => {
591                assert_eq!(text, "hello world");
592            }
593            other => panic!("Expected Submit, got {:?}", other),
594        }
595        assert!(
596            ed.editor.get_text().is_empty(),
597            "editor should clear on submit"
598        );
599    }
600
601    #[test]
602    fn test_enter_with_empty_text_returns_submit_empty() {
603        let mut ed = make_editor();
604        let action = ed.handle_input(&enter());
605        // Pi: Editor.submitValue() always calls onSubmit, even with empty text
606        match action {
607            InputAction::Submit(text) => {
608                assert_eq!(text, "", "empty submit should return empty string");
609            }
610            other => panic!("Expected Submit(\"\"), got {:?}", other),
611        }
612    }
613
614    // ── Pi-compat: text editing keys fall through to Editor ──
615
616    #[test]
617    fn test_ctrl_z_delegates_to_editor_undo() {
618        let mut ed = make_editor();
619        ed.editor.set_text("hello");
620        // Move cursor to end
621        ed.editor.set_text("hello world");
622        // Type more, then undo
623        ed.editor.handle_input(&char_key('!'));
624        assert_eq!(ed.editor.get_text(), "hello world!");
625        // Ctrl+Z should undo via Editor (no longer intercepted as Suspend)
626        let action = ed.handle_input(&ctrl('z'));
627        assert!(matches!(action, InputAction::Handled));
628        assert_eq!(ed.editor.get_text(), "hello world");
629    }
630
631    #[test]
632    fn test_ctrl_j_inserts_newline_via_editor() {
633        let mut ed = make_editor();
634        ed.editor.set_text("hello");
635        // Ctrl+J should add newline via Editor's add_newline()
636        let action = ed.handle_input(&ctrl('j'));
637        assert!(matches!(action, InputAction::Handled));
638        assert_eq!(ed.editor.get_text(), "hello\n");
639    }
640
641    #[test]
642    fn test_up_down_history_via_editor() {
643        let mut ed = make_editor();
644        // Add history entries like pi does
645        ed.editor.add_to_history("first");
646        ed.editor.add_to_history("second");
647        assert!(ed.editor.get_text().is_empty());
648
649        // Up should recall via the Editor's internal history (not app-level)
650        let action = ed.handle_input(&up());
651        assert!(matches!(action, InputAction::Handled));
652        assert_eq!(ed.editor.get_text(), "second");
653    }
654
655    #[test]
656    fn test_page_keys_delegated_to_editor() {
657        let mut ed = make_editor();
658        // PageUp/PageDown should be handled by Editor, not intercepted
659        let action = ed.handle_input(&page_up());
660        assert!(matches!(action, InputAction::Handled));
661        let action = ed.handle_input(&page_down());
662        assert!(matches!(action, InputAction::Handled));
663    }
664
665    #[test]
666    fn test_tab_delegated_to_editor() {
667        let mut ed = make_editor();
668        // Set some text with a slash command prefix
669        ed.set_slash_commands(vec![
670            SlashCommand {
671                name: "help".into(),
672                description: None,
673                argument_hint: None,
674                argument_completions: None,
675                get_argument_completions: None,
676            },
677            SlashCommand {
678                name: "history".into(),
679                description: None,
680                argument_hint: None,
681                argument_completions: None,
682                get_argument_completions: None,
683            },
684        ]);
685        ed.editor.set_text("/h");
686
687        // Tab should be handled by Editor (trigger autocomplete provider)
688        let _action = ed.handle_input(&ctrl(' ')); // Not Tab, but another way...
689        // Just verify Tab doesn't crash
690        let tab_key = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
691        let _action = ed.handle_input(&tab_key);
692        assert!(matches!(_action, InputAction::Handled));
693    }
694
695    // ── Printable chars ──
696
697    #[test]
698    fn test_printable_char_inserts_text() {
699        let mut ed = make_editor();
700        let action = ed.handle_input(&char_key('a'));
701        assert!(matches!(action, InputAction::Handled));
702        assert_eq!(ed.editor.get_text(), "a");
703    }
704
705    #[test]
706    fn test_backspace_deletes() {
707        let mut ed = make_editor();
708        ed.editor.set_text("abc");
709        let action = ed.handle_input(&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
710        assert!(matches!(action, InputAction::Handled));
711        assert_eq!(ed.editor.get_text(), "ab");
712    }
713
714    #[test]
715    fn test_arrow_left_moves_cursor() {
716        let mut ed = make_editor();
717        ed.editor.set_text("abc");
718        assert_eq!(ed.editor.get_cursor(), (0, 3));
719        let action = ed.handle_input(&KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
720        assert!(matches!(action, InputAction::Handled));
721        assert_eq!(ed.editor.get_cursor(), (0, 2));
722    }
723
724    #[test]
725    fn test_ctrl_k_deletes_to_line_end() {
726        let mut ed = make_editor();
727        ed.editor.set_text("hello world");
728        // Move cursor after "hello "
729        for _ in 0..6 {
730            ed.editor
731                .handle_input(&KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
732        }
733        assert_eq!(ed.editor.get_cursor(), (0, 5));
734        let action = ed.handle_input(&ctrl('k'));
735        assert!(matches!(action, InputAction::Handled));
736        assert_eq!(ed.editor.get_text(), "hello");
737    }
738
739    // ── History integration ──
740
741    #[test]
742    fn test_submit_adds_to_history() {
743        let mut ed = make_editor();
744        ed.editor.set_text("test");
745        let action = ed.handle_input(&enter());
746        assert!(matches!(action, InputAction::Submit(_)));
747        // History should now contain "test" - verify by pressing Up
748        let action2 = ed.handle_input(&up());
749        assert!(matches!(action2, InputAction::Handled));
750        assert_eq!(ed.editor.get_text(), "test");
751    }
752
753    // ── InputAction enum exhaustiveness ──
754
755    #[test]
756    fn test_input_action_debug() {
757        let variants = vec![
758            format!("{:?}", InputAction::Handled),
759            format!("{:?}", InputAction::Escape),
760            format!("{:?}", InputAction::Clear),
761            format!("{:?}", InputAction::Exit),
762            format!("{:?}", InputAction::ThinkingCycle),
763            format!("{:?}", InputAction::ModelSelector),
764            format!("{:?}", InputAction::ModelCycleForward),
765            format!("{:?}", InputAction::ModelCycleBackward),
766            format!("{:?}", InputAction::ToggleThinking),
767            format!("{:?}", InputAction::ToolsExpand),
768            format!("{:?}", InputAction::EditorExternal),
769            format!("{:?}", InputAction::Help),
770            format!("{:?}", InputAction::Submit("x".into())),
771            format!("{:?}", InputAction::FollowUp("x".into())),
772            format!("{:?}", InputAction::CompactToggle),
773            format!("{:?}", InputAction::Dequeue),
774        ];
775        for v in &variants {
776            assert!(!v.is_empty(), "Debug output should not be empty");
777        }
778    }
779}