Skip to main content

rab/agent/ui/
chat_editor.rs

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