Skip to main content

wisp/components/
text_input.rs

1use crate::keybindings::Keybindings;
2use std::path::PathBuf;
3use tui::{Component, Event, Frame, KeyCode, KeyEvent, KeyModifiers, Line, TextField, ViewContext};
4
5#[doc = include_str!("../docs/text_input.md")]
6pub struct TextInput {
7    field: TextField,
8    mentions: Vec<SelectedFileMention>,
9    keybindings: Keybindings,
10    history: PromptHistory,
11}
12
13pub enum TextInputMessage {
14    Submit,
15    OpenCommandPicker,
16    OpenFilePicker,
17}
18
19#[derive(Debug, Clone)]
20pub struct SelectedFileMention {
21    pub mention: String,
22    pub path: PathBuf,
23    pub display_name: String,
24}
25
26const MAX_HISTORY_ENTRIES: usize = 500;
27
28struct PromptHistory {
29    prompts: Vec<String>,
30    index: Option<usize>,
31    draft: Option<String>,
32}
33
34impl PromptHistory {
35    fn new() -> Self {
36        Self { prompts: Vec::new(), index: None, draft: None }
37    }
38
39    fn record(&mut self, prompt: &str) {
40        if prompt.is_empty() {
41            return;
42        }
43
44        self.prompts.push(prompt.to_string());
45        if self.prompts.len() > MAX_HISTORY_ENTRIES {
46            self.prompts.remove(0);
47        }
48    }
49
50    fn previous(&mut self, current_text: &str) -> Option<String> {
51        if self.prompts.is_empty() {
52            return None;
53        }
54
55        let next_index = match self.index {
56            None => {
57                self.draft = Some(current_text.to_string());
58                self.prompts.len() - 1
59            }
60            Some(index) if index > 0 => index - 1,
61            Some(_) => return None,
62        };
63
64        self.index = Some(next_index);
65        Some(self.prompts[next_index].clone())
66    }
67
68    fn next(&mut self) -> Option<String> {
69        let index = self.index?;
70
71        if index + 1 < self.prompts.len() {
72            let next_index = index + 1;
73            self.index = Some(next_index);
74            Some(self.prompts[next_index].clone())
75        } else {
76            let value = self.draft.take().unwrap_or_default();
77            self.index = None;
78            Some(value)
79        }
80    }
81
82    fn reset(&mut self) {
83        self.index = None;
84        self.draft = None;
85    }
86
87    fn is_navigating(&self) -> bool {
88        self.index.is_some()
89    }
90}
91
92impl Default for TextInput {
93    fn default() -> Self {
94        Self::new(Keybindings::default())
95    }
96}
97
98impl TextInput {
99    pub fn new(keybindings: Keybindings) -> Self {
100        Self { field: TextField::new(String::new()), mentions: Vec::new(), keybindings, history: PromptHistory::new() }
101    }
102
103    pub fn set_content_width(&mut self, width: usize) {
104        self.field.set_content_width(width);
105    }
106
107    pub fn buffer(&self) -> &str {
108        &self.field.value
109    }
110
111    /// Returns the visual cursor index, accounting for an active file picker
112    /// whose query extends beyond the `@` trigger character.
113    pub fn cursor_index(&self, picker_query_len: Option<usize>) -> usize {
114        if let Some(query_len) = picker_query_len {
115            let at_pos = self.active_mention_start().unwrap_or(self.field.value.len());
116            at_pos + 1 + query_len
117        } else {
118            self.field.cursor_pos()
119        }
120    }
121
122    #[cfg(test)]
123    pub fn mentions(&self) -> &[SelectedFileMention] {
124        &self.mentions
125    }
126
127    pub fn take_mentions(&mut self) -> Vec<SelectedFileMention> {
128        std::mem::take(&mut self.mentions)
129    }
130
131    pub fn set_input(&mut self, s: String) {
132        self.history.reset();
133        self.field.set_value(s);
134    }
135
136    pub fn set_cursor_pos(&mut self, pos: usize) {
137        self.field.set_cursor_pos(pos);
138    }
139
140    pub fn clear(&mut self) {
141        self.history.reset();
142        self.mentions.clear();
143        self.field.clear();
144    }
145
146    pub fn insert_char_at_cursor(&mut self, c: char) {
147        self.history.reset();
148        self.field.insert_at_cursor(c);
149    }
150
151    pub fn delete_char_before_cursor(&mut self) -> bool {
152        self.history.reset();
153        self.field.delete_before_cursor()
154    }
155
156    pub fn insert_paste(&mut self, text: &str) {
157        self.history.reset();
158        let filtered: String = text.chars().filter(|c| !c.is_control()).collect();
159        self.field.insert_str_at_cursor(&filtered);
160    }
161
162    pub fn apply_file_selection(&mut self, path: PathBuf, display_name: String) {
163        let mention = format!("@{display_name}");
164        self.mentions.push(SelectedFileMention { mention: mention.clone(), path, display_name });
165
166        if let Some(at_pos) = self.active_mention_start() {
167            let mut s = self.field.value[..at_pos].to_string();
168            s.push_str(&mention);
169            s.push(' ');
170            self.set_input(s);
171        }
172    }
173
174    fn active_mention_start(&self) -> Option<usize> {
175        mention_start(&self.field.value)
176    }
177
178    pub fn record_submission(&mut self, prompt: &str) {
179        self.history.record(prompt);
180    }
181
182    fn recall_older(&mut self) -> bool {
183        let Some(value) = self.history.previous(&self.field.value) else {
184            return false;
185        };
186        self.mentions.clear();
187        self.field.value = value;
188        self.field.set_cursor_pos(0);
189        true
190    }
191
192    fn recall_newer(&mut self) -> bool {
193        let Some(value) = self.history.next() else {
194            return false;
195        };
196        self.mentions.clear();
197        self.field.value = value;
198        self.field.set_cursor_pos(self.field.value.len());
199        true
200    }
201}
202
203impl Component for TextInput {
204    type Message = TextInputMessage;
205
206    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
207        match event {
208            Event::Paste(text) => {
209                self.insert_paste(text);
210                Some(vec![])
211            }
212            Event::Key(key_event) => self.handle_key(key_event).await,
213            _ => None,
214        }
215    }
216
217    fn render(&mut self, _context: &ViewContext) -> Frame {
218        Frame::new(vec![Line::new(self.field.value.clone())])
219    }
220}
221
222impl TextInput {
223    async fn handle_key(&mut self, key_event: &KeyEvent) -> Option<Vec<TextInputMessage>> {
224        if is_newline_key(key_event) {
225            self.history.reset();
226            self.field.insert_at_cursor('\n');
227            return Some(vec![]);
228        }
229
230        if self.keybindings.submit.matches(*key_event) {
231            return Some(vec![TextInputMessage::Submit]);
232        }
233
234        if self.keybindings.open_command_picker.matches(*key_event) && self.field.value.is_empty() {
235            self.history.reset();
236            if let Some(c) = self.keybindings.open_command_picker.char() {
237                self.field.insert_at_cursor(c);
238            }
239            return Some(vec![TextInputMessage::OpenCommandPicker]);
240        }
241
242        if self.keybindings.open_file_picker.matches(*key_event) {
243            self.history.reset();
244            if let Some(c) = self.keybindings.open_file_picker.char() {
245                self.field.insert_at_cursor(c);
246            }
247            return Some(vec![TextInputMessage::OpenFilePicker]);
248        }
249
250        match key_event.code {
251            KeyCode::Up if self.field.is_cursor_on_first_visual_line() && self.recall_older() => {
252                return Some(vec![]);
253            }
254            KeyCode::Down if self.field.is_cursor_on_last_visual_line() && self.recall_newer() => {
255                return Some(vec![]);
256            }
257            _ => {}
258        }
259
260        let before_len = self.field.value.len();
261        let result = self.field.on_event(&Event::Key(*key_event)).await;
262        if self.history.is_navigating() && self.field.value.len() != before_len {
263            self.history.reset();
264        }
265        result.map(|_| vec![])
266    }
267}
268
269pub(crate) fn is_newline_key(key_event: &KeyEvent) -> bool {
270    (key_event.code == KeyCode::Enter && is_newline_modifier(key_event.modifiers))
271        || (key_event.code == KeyCode::Char('j') && key_event.modifiers.contains(KeyModifiers::CONTROL))
272}
273
274fn is_newline_modifier(modifiers: KeyModifiers) -> bool {
275    modifiers.contains(KeyModifiers::SHIFT) || modifiers.contains(KeyModifiers::ALT)
276}
277
278fn mention_start(input: &str) -> Option<usize> {
279    let at_pos = input.rfind('@')?;
280    let prefix = &input[..at_pos];
281    if prefix.is_empty() || prefix.chars().last().is_some_and(char::is_whitespace) { Some(at_pos) } else { None }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use tui::KeyCode;
288    use tui::KeyModifiers;
289
290    fn key(code: KeyCode) -> Event {
291        key_with_modifiers(code, KeyModifiers::NONE)
292    }
293
294    fn key_with_modifiers(code: KeyCode, modifiers: KeyModifiers) -> Event {
295        Event::Key(KeyEvent::new(code, modifiers))
296    }
297
298    fn input_with(text: &str, cursor: Option<usize>) -> TextInput {
299        let mut input = TextInput::default();
300        input.set_input(text.to_string());
301        if let Some(pos) = cursor {
302            input.set_cursor_pos(pos);
303        }
304        input
305    }
306
307    fn input_with_width(text: &str, cursor: usize, width: usize) -> TextInput {
308        let mut input = TextInput::default();
309        input.set_content_width(width);
310        input.set_input(text.to_string());
311        input.set_cursor_pos(cursor);
312        input
313    }
314
315    fn cursor(input: &TextInput) -> usize {
316        input.cursor_index(None)
317    }
318
319    #[tokio::test]
320    async fn arrow_key_cursor_movement() {
321        // (initial_text, initial_cursor, key_code, expected_cursor)
322        let cases = [
323            ("hello", None, KeyCode::Left, 4, "left from end"),
324            ("hello", Some(2), KeyCode::Right, 3, "right from middle"),
325            ("hello", Some(0), KeyCode::Left, 0, "left at start stays"),
326            ("hello", None, KeyCode::Right, 5, "right at end stays"),
327            ("hello", Some(3), KeyCode::Home, 0, "home moves to start"),
328            ("hello", Some(1), KeyCode::End, 5, "end moves to end"),
329        ];
330        for (text, cur, code, expected, label) in cases {
331            let mut input = input_with(text, cur);
332            input.on_event(&key(code)).await;
333            assert_eq!(cursor(&input), expected, "{label}");
334        }
335    }
336
337    #[tokio::test]
338    async fn typing_inserts_at_cursor_position() {
339        let mut input = input_with("hllo", Some(1));
340        input.on_event(&key(KeyCode::Char('e'))).await;
341        assert_eq!(input.buffer(), "hello");
342        assert_eq!(cursor(&input), 2);
343    }
344
345    #[tokio::test]
346    async fn backspace_at_cursor_middle_deletes_correct_char() {
347        let mut input = input_with("hello", Some(3));
348        input.on_event(&key(KeyCode::Backspace)).await;
349        assert_eq!(input.buffer(), "helo");
350        assert_eq!(cursor(&input), 2);
351    }
352
353    #[tokio::test]
354    async fn backspace_at_start_does_nothing() {
355        let mut input = input_with("hello", Some(0));
356        let outcome = input.on_event(&key(KeyCode::Backspace)).await;
357        assert!(outcome.is_some());
358        assert_eq!(input.buffer(), "hello");
359        assert_eq!(cursor(&input), 0);
360    }
361
362    #[tokio::test]
363    async fn multibyte_utf8_cursor_navigation() {
364        // "a中b" — 'a' is 1 byte, '中' is 3 bytes, 'b' is 1 byte = 5 bytes total
365        let mut input = input_with("a中b", None);
366
367        let steps: &[(KeyCode, usize)] = &[
368            (KeyCode::Left, 4),  // before 'b'
369            (KeyCode::Left, 1),  // before '中'
370            (KeyCode::Left, 0),  // before 'a'
371            (KeyCode::Right, 1), // after 'a'
372            (KeyCode::Right, 4), // after '中'
373        ];
374        for (code, expected) in steps {
375            input.on_event(&key(*code)).await;
376            assert_eq!(cursor(&input), *expected);
377        }
378    }
379
380    #[test]
381    fn paste_inserts_at_cursor_position() {
382        let mut input = input_with("hd", Some(1));
383        input.insert_paste("ello worl");
384        assert_eq!(input.buffer(), "hello world");
385        assert_eq!(cursor(&input), 10);
386    }
387
388    #[tokio::test]
389    async fn slash_on_empty_returns_open_command_picker() {
390        let mut input = TextInput::default();
391        let outcome = input.on_event(&key(KeyCode::Char('/'))).await;
392        assert!(matches!(outcome.as_deref(), Some([TextInputMessage::OpenCommandPicker])));
393        assert_eq!(input.buffer(), "/");
394    }
395
396    #[tokio::test]
397    async fn at_sign_returns_open_file_picker() {
398        let mut input = TextInput::default();
399        let outcome = input.on_event(&key(KeyCode::Char('@'))).await;
400        assert!(matches!(outcome.as_deref(), Some([TextInputMessage::OpenFilePicker])));
401        assert_eq!(input.buffer(), "@");
402    }
403
404    #[tokio::test]
405    async fn enter_returns_submit() {
406        let mut input = input_with("hello", None);
407        let outcome = input.on_event(&key(KeyCode::Enter)).await;
408        assert!(matches!(outcome.as_deref(), Some([TextInputMessage::Submit])));
409    }
410
411    #[tokio::test]
412    async fn shift_enter_inserts_newline() {
413        let mut input = input_with("hello", None);
414        let outcome = input.on_event(&key_with_modifiers(KeyCode::Enter, KeyModifiers::SHIFT)).await;
415
416        assert!(matches!(outcome.as_deref(), Some([])));
417        assert_eq!(input.buffer(), "hello\n");
418        assert_eq!(cursor(&input), 6);
419    }
420
421    #[tokio::test]
422    async fn shift_enter_inserts_newline_at_cursor() {
423        let mut input = input_with("helloworld", Some(5));
424        let outcome = input.on_event(&key_with_modifiers(KeyCode::Enter, KeyModifiers::SHIFT)).await;
425
426        assert!(matches!(outcome.as_deref(), Some([])));
427        assert_eq!(input.buffer(), "hello\nworld");
428        assert_eq!(cursor(&input), 6);
429    }
430
431    #[tokio::test]
432    async fn ctrl_j_inserts_newline() {
433        let mut input = input_with("hello", None);
434        let outcome = input.on_event(&key_with_modifiers(KeyCode::Char('j'), KeyModifiers::CONTROL)).await;
435
436        assert!(matches!(outcome.as_deref(), Some([])));
437        assert_eq!(input.buffer(), "hello\n");
438        assert_eq!(cursor(&input), 6);
439    }
440
441    #[test]
442    fn file_selection_updates_mentions_and_buffer() {
443        let mut input = input_with("@fo", None);
444        input.apply_file_selection(PathBuf::from("foo.rs"), "foo.rs".to_string());
445        assert_eq!(input.buffer(), "@foo.rs ");
446        assert_eq!(input.mentions().len(), 1);
447        assert_eq!(input.mentions()[0].mention, "@foo.rs");
448    }
449
450    #[test]
451    fn cursor_index_with_and_without_picker() {
452        let input = input_with("hello", Some(3));
453        assert_eq!(input.cursor_index(None), 3);
454
455        let input = input_with("@fo", None);
456        // Picker has 2-char query ("fo"), @ is at position 0
457        assert_eq!(input.cursor_index(Some(2)), 3); // 0 + 1 + 2
458    }
459
460    #[test]
461    fn clear_resets_buffer_and_cursor() {
462        let mut input = input_with("hello", None);
463        input.clear();
464        assert_eq!(input.buffer(), "");
465        assert_eq!(cursor(&input), 0);
466    }
467
468    #[tokio::test]
469    async fn vertical_cursor_movement_in_wrapped_text() {
470        let cases = [
471            (8, KeyCode::Up, 2, "up from wrapped row to same visual column"),
472            (3, KeyCode::Down, 9, "down to wrapped row at same visual column"),
473        ];
474        for (cur, code, expected, label) in cases {
475            let mut input = input_with_width("hello world", cur, 5);
476            input.on_event(&key(code)).await;
477            assert_eq!(cursor(&input), expected, "{label}");
478        }
479    }
480
481    #[tokio::test]
482    async fn up_on_first_row_goes_home_down_on_last_row_goes_end() {
483        let cases =
484            [(3, KeyCode::Up, 0, "up on single row -> home"), (0, KeyCode::Down, 5, "down on single row -> end")];
485        for (cur, code, expected, label) in cases {
486            let mut input = input_with_width("hello", cur, 20);
487            input.on_event(&key(code)).await;
488            assert_eq!(cursor(&input), expected, "{label}");
489        }
490    }
491
492    fn input_with_history(history: &[&str]) -> TextInput {
493        let mut input = TextInput::default();
494        for entry in history {
495            input.record_submission(entry);
496        }
497        input
498    }
499
500    #[tokio::test]
501    async fn up_recalls_older_history_entry() {
502        let mut input = input_with_history(&["first", "second", "third"]);
503        assert_eq!(input.buffer(), "");
504
505        input.on_event(&key(KeyCode::Up)).await;
506        assert_eq!(input.buffer(), "third");
507        assert_eq!(cursor(&input), 0);
508    }
509
510    #[tokio::test]
511    async fn repeated_up_pages_to_older_entries() {
512        let mut input = input_with_history(&["first", "second", "third"]);
513
514        input.on_event(&key(KeyCode::Up)).await;
515        assert_eq!(input.buffer(), "third");
516
517        input.on_event(&key(KeyCode::Up)).await;
518        assert_eq!(input.buffer(), "second");
519
520        input.on_event(&key(KeyCode::Up)).await;
521        assert_eq!(input.buffer(), "first");
522    }
523
524    #[tokio::test]
525    async fn up_stops_at_oldest_entry() {
526        let mut input = input_with_history(&["first", "second"]);
527
528        input.on_event(&key(KeyCode::Up)).await;
529        assert_eq!(input.buffer(), "second");
530
531        input.on_event(&key(KeyCode::Up)).await;
532        assert_eq!(input.buffer(), "first");
533
534        let before = input.buffer().to_string();
535        input.on_event(&key(KeyCode::Up)).await;
536        assert_eq!(input.buffer(), before);
537    }
538
539    #[tokio::test]
540    async fn down_recalls_newer_entry() {
541        let mut input = input_with_history(&["first", "second", "third"]);
542
543        input.on_event(&key(KeyCode::Up)).await;
544        input.on_event(&key(KeyCode::Up)).await;
545        input.on_event(&key(KeyCode::Up)).await;
546        assert_eq!(input.buffer(), "first");
547
548        input.on_event(&key(KeyCode::Down)).await;
549        assert_eq!(input.buffer(), "second");
550        assert_eq!(cursor(&input), 6);
551    }
552
553    #[tokio::test]
554    async fn down_past_newest_restores_draft() {
555        let mut input = input_with_history(&["first", "second"]);
556        input.set_input("my draft".to_string());
557        input.on_event(&key(KeyCode::Up)).await;
558        assert_eq!(input.buffer(), "second");
559
560        input.on_event(&key(KeyCode::Down)).await;
561        assert_eq!(input.buffer(), "my draft");
562        assert_eq!(cursor(&input), 8);
563    }
564
565    #[tokio::test]
566    async fn down_on_live_prompt_does_nothing() {
567        let mut input = input_with_history(&["first"]);
568        let outcome = input.on_event(&key(KeyCode::Down)).await;
569        assert_eq!(input.buffer(), "");
570        assert!(outcome.is_some());
571    }
572
573    #[tokio::test]
574    async fn empty_history_up_does_nothing_special() {
575        let mut input = TextInput::default();
576        input.on_event(&key(KeyCode::Up)).await;
577        assert_eq!(input.buffer(), "");
578        assert_eq!(cursor(&input), 0);
579    }
580
581    #[tokio::test]
582    async fn typing_resets_history_navigation() {
583        let mut input = input_with_history(&["first", "second"]);
584        input.on_event(&key(KeyCode::Up)).await;
585        assert_eq!(input.buffer(), "second");
586
587        input.on_event(&key(KeyCode::Char('x'))).await;
588        assert_eq!(input.buffer(), "xsecond");
589
590        input.on_event(&key(KeyCode::Down)).await;
591        assert_eq!(cursor(&input), 7);
592    }
593
594    #[tokio::test]
595    async fn up_on_first_row_of_wrapped_text_navigates_history() {
596        let mut input = TextInput::default();
597        input.set_content_width(5);
598        input.record_submission("old entry");
599        input.set_input("hello world".to_string());
600        input.set_cursor_pos(3); // on row 0
601        input.on_event(&key(KeyCode::Up)).await;
602        assert_eq!(input.buffer(), "old entry");
603    }
604
605    #[tokio::test]
606    async fn up_on_middle_row_of_wrapped_text_keeps_normal_navigation() {
607        let mut input = TextInput::default();
608        input.set_content_width(5);
609        input.record_submission("old entry");
610        input.set_input("hello world".to_string());
611        input.set_cursor_pos(8);
612        input.on_event(&key(KeyCode::Up)).await;
613
614        assert_eq!(input.buffer(), "hello world");
615        assert_eq!(cursor(&input), 2);
616    }
617
618    #[tokio::test]
619    async fn down_on_last_row_of_recalled_single_line_navigates_history() {
620        let mut input = TextInput::default();
621        input.set_content_width(20);
622        input.record_submission("old entry");
623        input.record_submission("newer entry");
624        input.set_input("current draft".to_string());
625
626        input.on_event(&key(KeyCode::Up)).await;
627        assert_eq!(input.buffer(), "newer entry");
628
629        input.on_event(&key(KeyCode::Up)).await;
630        assert_eq!(input.buffer(), "old entry");
631
632        input.on_event(&key(KeyCode::Down)).await;
633        assert_eq!(input.buffer(), "newer entry");
634
635        input.on_event(&key(KeyCode::Down)).await;
636        assert_eq!(input.buffer(), "current draft");
637    }
638
639    #[tokio::test]
640    async fn down_on_non_last_row_of_wrapped_text_keeps_normal_navigation() {
641        let mut input = TextInput::default();
642        input.set_content_width(5);
643        input.set_input("hello world".to_string());
644        input.set_cursor_pos(3);
645        input.on_event(&key(KeyCode::Down)).await;
646
647        assert_eq!(input.buffer(), "hello world");
648        assert_eq!(cursor(&input), 9);
649    }
650
651    #[tokio::test]
652    async fn recalling_history_clears_mentions() {
653        let mut input = TextInput::default();
654        input.apply_file_selection(PathBuf::from("foo.rs"), "foo.rs".to_string());
655        assert_eq!(input.mentions().len(), 1);
656
657        input.record_submission("some prompt");
658        input.set_input(String::new());
659
660        input.on_event(&key(KeyCode::Up)).await;
661        assert_eq!(input.mentions().len(), 0);
662        assert_eq!(input.buffer(), "some prompt");
663    }
664
665    #[test]
666    fn record_submission_adds_to_history() {
667        let mut input = TextInput::default();
668        input.record_submission("first");
669        input.record_submission("second");
670        input.record_submission("third");
671        assert_eq!(input.history.prompts, vec!["first", "second", "third"]);
672    }
673}