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#[derive(Debug)]
22pub enum InputAction {
23 Handled,
25 Escape,
27 Clear,
29 Exit,
31 ThinkingCycle,
33 ModelSelector,
35 ModelCycleForward,
37 ModelCycleBackward,
39 ToggleThinking,
41 ToolsExpand,
43 EditorExternal,
45 Help,
47 Submit(String),
49 FollowUp(String),
51 Dequeue,
53 CompactToggle,
55}
56
57#[allow(clippy::type_complexity)]
71pub struct ChatEditor {
72 pub editor: Editor,
73 cwd: std::path::PathBuf,
75 slash_commands: Vec<SlashCommand>,
77 on_extension_shortcut: Option<Box<dyn FnMut(&KeyEvent) -> bool + Send>>,
79 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 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 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 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 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 pub fn check_autocomplete(&mut self) {
164 if !self.editor.autocomplete_active {
167 self.editor.try_trigger_autocomplete();
168 }
169 }
170
171 pub fn set_cwd(&mut self, cwd: std::path::PathBuf) {
173 self.cwd = cwd;
174 self.rebuild_autocomplete_provider();
175 }
176
177 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 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 if let Some(ref mut handler) = self.on_extension_shortcut
240 && handler(key)
241 {
242 return InputAction::Handled;
243 }
244
245 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 if kb.matches(key, ACTION_APP_CLEAR) {
261 return InputAction::Clear;
262 }
263
264 if kb.matches(key, ACTION_APP_EXIT) {
266 if self.editor.get_text().is_empty() {
267 return InputAction::Exit;
268 }
269 self.editor.handle_input(key);
271 return InputAction::Handled;
272 }
273
274 if kb.matches(key, ACTION_APP_THINKING_CYCLE) {
276 return InputAction::ThinkingCycle;
277 }
278
279 if kb.matches(key, ACTION_APP_MODEL_SELECTOR) {
281 return InputAction::ModelSelector;
282 }
283
284 if kb.matches(key, ACTION_APP_MODEL_CYCLE_FORWARD) {
286 return InputAction::ModelCycleForward;
287 }
288
289 if kb.matches(key, ACTION_APP_MODEL_CYCLE_BACKWARD) {
291 return InputAction::ModelCycleBackward;
292 }
293
294 if kb.matches(key, ACTION_APP_TOGGLE_THINKING) {
296 return InputAction::ToggleThinking;
297 }
298
299 if kb.matches(key, ACTION_APP_TOOLS_EXPAND) {
301 return InputAction::ToolsExpand;
302 }
303
304 if kb.matches(key, ACTION_APP_EDITOR_EXTERNAL) {
306 return InputAction::EditorExternal;
307 }
308
309 if kb.matches(key, ACTION_APP_HELP) {
311 return InputAction::Help;
312 }
313
314 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 if kb.matches(key, ACTION_APP_MESSAGE_DEQUEUE) {
327 return InputAction::Dequeue;
328 }
329
330 if kb.matches(key, ACTION_APP_COMPACT_TOGGLE) {
332 return InputAction::CompactToggle;
333 }
334
335 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 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 if has_content {
365 self.editor.add_to_history(&text);
366 }
367 return InputAction::Submit(text);
368 }
369 return InputAction::Handled;
370 }
371
372 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 #[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 ed.editor.handle_input(&ctrl('l')); ed.editor.set_text("/h");
457 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 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 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 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 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 #[test]
642 fn test_ctrl_z_delegates_to_editor_undo() {
643 let mut ed = make_editor();
644 ed.editor.set_text("hello");
645 ed.editor.set_text("hello world");
647 ed.editor.handle_input(&char_key('!'));
649 assert_eq!(ed.editor.get_text(), "hello world!");
650 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 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 ed.editor.add_to_history("first");
671 ed.editor.add_to_history("second");
672 assert!(ed.editor.get_text().is_empty());
673
674 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 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 ed.set_slash_commands(vec!["help".into(), "history".into()]);
695 ed.editor.set_text("/h");
696
697 let _action = ed.handle_input(&ctrl(' ')); 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 #[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 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 #[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 let action2 = ed.handle_input(&up());
759 assert!(matches!(action2, InputAction::Handled));
760 assert_eq!(ed.editor.get_text(), "test");
761 }
762
763 #[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}