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#[derive(Debug)]
23pub enum InputAction {
24 Handled,
26 Escape,
28 Clear,
30 Exit,
32 ThinkingCycle,
34 ModelSelector,
36 ModelCycleForward,
38 ModelCycleBackward,
40 ToggleThinking,
42 ToolsExpand,
44 EditorExternal,
46 Help,
48 Submit(String),
50 FollowUp(String),
52 Dequeue,
54 CompactToggle,
56}
57
58#[allow(clippy::type_complexity)]
72pub struct ChatEditor {
73 pub editor: Editor,
74 cwd: std::path::PathBuf,
76 slash_commands: Vec<SlashCommand>,
78 on_extension_shortcut: Option<Box<dyn FnMut(&KeyEvent) -> bool + Send>>,
80 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 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 pub fn set_slash_commands(&mut self, commands: Vec<SlashCommand>) {
108 self.slash_commands = commands;
109 self.rebuild_autocomplete_provider();
110 }
111
112 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 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 pub fn check_autocomplete(&mut self) {
130 if !self.editor.autocomplete_active {
133 self.editor.try_trigger_autocomplete();
134 }
135 }
136
137 pub fn set_cwd(&mut self, cwd: std::path::PathBuf) {
139 self.cwd = cwd;
140 self.rebuild_autocomplete_provider();
141 }
142
143 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 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 if let Some(ref mut handler) = self.on_extension_shortcut
204 && handler(key)
205 {
206 return InputAction::Handled;
207 }
208
209 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 if kb.matches(key, ACTION_APP_CLEAR) {
225 return InputAction::Clear;
226 }
227
228 if kb.matches(key, ACTION_APP_EXIT) {
230 if self.editor.get_text().is_empty() {
231 return InputAction::Exit;
232 }
233 self.editor.handle_input(key);
235 return InputAction::Handled;
236 }
237
238 if kb.matches(key, ACTION_APP_THINKING_CYCLE) {
240 return InputAction::ThinkingCycle;
241 }
242
243 if kb.matches(key, ACTION_APP_MODEL_SELECTOR) {
245 return InputAction::ModelSelector;
246 }
247
248 if kb.matches(key, ACTION_APP_MODEL_CYCLE_FORWARD) {
250 return InputAction::ModelCycleForward;
251 }
252
253 if kb.matches(key, ACTION_APP_MODEL_CYCLE_BACKWARD) {
255 return InputAction::ModelCycleBackward;
256 }
257
258 if kb.matches(key, ACTION_APP_TOGGLE_THINKING) {
260 return InputAction::ToggleThinking;
261 }
262
263 if kb.matches(key, ACTION_APP_TOOLS_EXPAND) {
265 return InputAction::ToolsExpand;
266 }
267
268 if kb.matches(key, ACTION_APP_EDITOR_EXTERNAL) {
270 return InputAction::EditorExternal;
271 }
272
273 if kb.matches(key, ACTION_APP_HELP) {
275 return InputAction::Help;
276 }
277
278 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 if kb.matches(key, ACTION_APP_MESSAGE_DEQUEUE) {
291 return InputAction::Dequeue;
292 }
293
294 if kb.matches(key, ACTION_APP_COMPACT_TOGGLE) {
296 return InputAction::CompactToggle;
297 }
298
299 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 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 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 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 #[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 ed.editor.handle_input(&ctrl('l')); ed.editor.set_text("/h");
432 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 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 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 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 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 #[test]
617 fn test_ctrl_z_delegates_to_editor_undo() {
618 let mut ed = make_editor();
619 ed.editor.set_text("hello");
620 ed.editor.set_text("hello world");
622 ed.editor.handle_input(&char_key('!'));
624 assert_eq!(ed.editor.get_text(), "hello world!");
625 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 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 ed.editor.add_to_history("first");
646 ed.editor.add_to_history("second");
647 assert!(ed.editor.get_text().is_empty());
648
649 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 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 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 let _action = ed.handle_input(&ctrl(' ')); 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 #[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 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 #[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 let action2 = ed.handle_input(&up());
749 assert!(matches!(action2, InputAction::Handled));
750 assert_eq!(ed.editor.get_text(), "test");
751 }
752
753 #[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}