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 key_event.code == KeyCode::Enter && is_newline_modifier(key_event.modifiers) {
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_modifier(modifiers: KeyModifiers) -> bool {
270    modifiers.contains(KeyModifiers::SHIFT) || modifiers.contains(KeyModifiers::ALT)
271}
272
273fn mention_start(input: &str) -> Option<usize> {
274    let at_pos = input.rfind('@')?;
275    let prefix = &input[..at_pos];
276    if prefix.is_empty() || prefix.chars().last().is_some_and(char::is_whitespace) { Some(at_pos) } else { None }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use tui::KeyCode;
283    use tui::KeyModifiers;
284
285    fn key(code: KeyCode) -> Event {
286        key_with_modifiers(code, KeyModifiers::NONE)
287    }
288
289    fn key_with_modifiers(code: KeyCode, modifiers: KeyModifiers) -> Event {
290        Event::Key(KeyEvent::new(code, modifiers))
291    }
292
293    fn input_with(text: &str, cursor: Option<usize>) -> TextInput {
294        let mut input = TextInput::default();
295        input.set_input(text.to_string());
296        if let Some(pos) = cursor {
297            input.set_cursor_pos(pos);
298        }
299        input
300    }
301
302    fn input_with_width(text: &str, cursor: usize, width: usize) -> TextInput {
303        let mut input = TextInput::default();
304        input.set_content_width(width);
305        input.set_input(text.to_string());
306        input.set_cursor_pos(cursor);
307        input
308    }
309
310    fn cursor(input: &TextInput) -> usize {
311        input.cursor_index(None)
312    }
313
314    #[tokio::test]
315    async fn arrow_key_cursor_movement() {
316        // (initial_text, initial_cursor, key_code, expected_cursor)
317        let cases = [
318            ("hello", None, KeyCode::Left, 4, "left from end"),
319            ("hello", Some(2), KeyCode::Right, 3, "right from middle"),
320            ("hello", Some(0), KeyCode::Left, 0, "left at start stays"),
321            ("hello", None, KeyCode::Right, 5, "right at end stays"),
322            ("hello", Some(3), KeyCode::Home, 0, "home moves to start"),
323            ("hello", Some(1), KeyCode::End, 5, "end moves to end"),
324        ];
325        for (text, cur, code, expected, label) in cases {
326            let mut input = input_with(text, cur);
327            input.on_event(&key(code)).await;
328            assert_eq!(cursor(&input), expected, "{label}");
329        }
330    }
331
332    #[tokio::test]
333    async fn typing_inserts_at_cursor_position() {
334        let mut input = input_with("hllo", Some(1));
335        input.on_event(&key(KeyCode::Char('e'))).await;
336        assert_eq!(input.buffer(), "hello");
337        assert_eq!(cursor(&input), 2);
338    }
339
340    #[tokio::test]
341    async fn backspace_at_cursor_middle_deletes_correct_char() {
342        let mut input = input_with("hello", Some(3));
343        input.on_event(&key(KeyCode::Backspace)).await;
344        assert_eq!(input.buffer(), "helo");
345        assert_eq!(cursor(&input), 2);
346    }
347
348    #[tokio::test]
349    async fn backspace_at_start_does_nothing() {
350        let mut input = input_with("hello", Some(0));
351        let outcome = input.on_event(&key(KeyCode::Backspace)).await;
352        assert!(outcome.is_some());
353        assert_eq!(input.buffer(), "hello");
354        assert_eq!(cursor(&input), 0);
355    }
356
357    #[tokio::test]
358    async fn multibyte_utf8_cursor_navigation() {
359        // "a中b" — 'a' is 1 byte, '中' is 3 bytes, 'b' is 1 byte = 5 bytes total
360        let mut input = input_with("a中b", None);
361
362        let steps: &[(KeyCode, usize)] = &[
363            (KeyCode::Left, 4),  // before 'b'
364            (KeyCode::Left, 1),  // before '中'
365            (KeyCode::Left, 0),  // before 'a'
366            (KeyCode::Right, 1), // after 'a'
367            (KeyCode::Right, 4), // after '中'
368        ];
369        for (code, expected) in steps {
370            input.on_event(&key(*code)).await;
371            assert_eq!(cursor(&input), *expected);
372        }
373    }
374
375    #[test]
376    fn paste_inserts_at_cursor_position() {
377        let mut input = input_with("hd", Some(1));
378        input.insert_paste("ello worl");
379        assert_eq!(input.buffer(), "hello world");
380        assert_eq!(cursor(&input), 10);
381    }
382
383    #[tokio::test]
384    async fn slash_on_empty_returns_open_command_picker() {
385        let mut input = TextInput::default();
386        let outcome = input.on_event(&key(KeyCode::Char('/'))).await;
387        assert!(matches!(outcome.as_deref(), Some([TextInputMessage::OpenCommandPicker])));
388        assert_eq!(input.buffer(), "/");
389    }
390
391    #[tokio::test]
392    async fn at_sign_returns_open_file_picker() {
393        let mut input = TextInput::default();
394        let outcome = input.on_event(&key(KeyCode::Char('@'))).await;
395        assert!(matches!(outcome.as_deref(), Some([TextInputMessage::OpenFilePicker])));
396        assert_eq!(input.buffer(), "@");
397    }
398
399    #[tokio::test]
400    async fn enter_returns_submit() {
401        let mut input = input_with("hello", None);
402        let outcome = input.on_event(&key(KeyCode::Enter)).await;
403        assert!(matches!(outcome.as_deref(), Some([TextInputMessage::Submit])));
404    }
405
406    #[tokio::test]
407    async fn shift_enter_inserts_newline() {
408        let mut input = input_with("hello", None);
409        let outcome = input.on_event(&key_with_modifiers(KeyCode::Enter, KeyModifiers::SHIFT)).await;
410
411        assert!(matches!(outcome.as_deref(), Some([])));
412        assert_eq!(input.buffer(), "hello\n");
413        assert_eq!(cursor(&input), 6);
414    }
415
416    #[tokio::test]
417    async fn shift_enter_inserts_newline_at_cursor() {
418        let mut input = input_with("helloworld", Some(5));
419        let outcome = input.on_event(&key_with_modifiers(KeyCode::Enter, KeyModifiers::SHIFT)).await;
420
421        assert!(matches!(outcome.as_deref(), Some([])));
422        assert_eq!(input.buffer(), "hello\nworld");
423        assert_eq!(cursor(&input), 6);
424    }
425
426    #[test]
427    fn file_selection_updates_mentions_and_buffer() {
428        let mut input = input_with("@fo", None);
429        input.apply_file_selection(PathBuf::from("foo.rs"), "foo.rs".to_string());
430        assert_eq!(input.buffer(), "@foo.rs ");
431        assert_eq!(input.mentions().len(), 1);
432        assert_eq!(input.mentions()[0].mention, "@foo.rs");
433    }
434
435    #[test]
436    fn cursor_index_with_and_without_picker() {
437        let input = input_with("hello", Some(3));
438        assert_eq!(input.cursor_index(None), 3);
439
440        let input = input_with("@fo", None);
441        // Picker has 2-char query ("fo"), @ is at position 0
442        assert_eq!(input.cursor_index(Some(2)), 3); // 0 + 1 + 2
443    }
444
445    #[test]
446    fn clear_resets_buffer_and_cursor() {
447        let mut input = input_with("hello", None);
448        input.clear();
449        assert_eq!(input.buffer(), "");
450        assert_eq!(cursor(&input), 0);
451    }
452
453    #[tokio::test]
454    async fn vertical_cursor_movement_in_wrapped_text() {
455        let cases = [
456            (8, KeyCode::Up, 2, "up from wrapped row to same visual column"),
457            (3, KeyCode::Down, 9, "down to wrapped row at same visual column"),
458        ];
459        for (cur, code, expected, label) in cases {
460            let mut input = input_with_width("hello world", cur, 5);
461            input.on_event(&key(code)).await;
462            assert_eq!(cursor(&input), expected, "{label}");
463        }
464    }
465
466    #[tokio::test]
467    async fn up_on_first_row_goes_home_down_on_last_row_goes_end() {
468        let cases =
469            [(3, KeyCode::Up, 0, "up on single row -> home"), (0, KeyCode::Down, 5, "down on single row -> end")];
470        for (cur, code, expected, label) in cases {
471            let mut input = input_with_width("hello", cur, 20);
472            input.on_event(&key(code)).await;
473            assert_eq!(cursor(&input), expected, "{label}");
474        }
475    }
476
477    fn input_with_history(history: &[&str]) -> TextInput {
478        let mut input = TextInput::default();
479        for entry in history {
480            input.record_submission(entry);
481        }
482        input
483    }
484
485    #[tokio::test]
486    async fn up_recalls_older_history_entry() {
487        let mut input = input_with_history(&["first", "second", "third"]);
488        assert_eq!(input.buffer(), "");
489
490        input.on_event(&key(KeyCode::Up)).await;
491        assert_eq!(input.buffer(), "third");
492        assert_eq!(cursor(&input), 0);
493    }
494
495    #[tokio::test]
496    async fn repeated_up_pages_to_older_entries() {
497        let mut input = input_with_history(&["first", "second", "third"]);
498
499        input.on_event(&key(KeyCode::Up)).await;
500        assert_eq!(input.buffer(), "third");
501
502        input.on_event(&key(KeyCode::Up)).await;
503        assert_eq!(input.buffer(), "second");
504
505        input.on_event(&key(KeyCode::Up)).await;
506        assert_eq!(input.buffer(), "first");
507    }
508
509    #[tokio::test]
510    async fn up_stops_at_oldest_entry() {
511        let mut input = input_with_history(&["first", "second"]);
512
513        input.on_event(&key(KeyCode::Up)).await;
514        assert_eq!(input.buffer(), "second");
515
516        input.on_event(&key(KeyCode::Up)).await;
517        assert_eq!(input.buffer(), "first");
518
519        let before = input.buffer().to_string();
520        input.on_event(&key(KeyCode::Up)).await;
521        assert_eq!(input.buffer(), before);
522    }
523
524    #[tokio::test]
525    async fn down_recalls_newer_entry() {
526        let mut input = input_with_history(&["first", "second", "third"]);
527
528        input.on_event(&key(KeyCode::Up)).await;
529        input.on_event(&key(KeyCode::Up)).await;
530        input.on_event(&key(KeyCode::Up)).await;
531        assert_eq!(input.buffer(), "first");
532
533        input.on_event(&key(KeyCode::Down)).await;
534        assert_eq!(input.buffer(), "second");
535        assert_eq!(cursor(&input), 6);
536    }
537
538    #[tokio::test]
539    async fn down_past_newest_restores_draft() {
540        let mut input = input_with_history(&["first", "second"]);
541        input.set_input("my draft".to_string());
542        input.on_event(&key(KeyCode::Up)).await;
543        assert_eq!(input.buffer(), "second");
544
545        input.on_event(&key(KeyCode::Down)).await;
546        assert_eq!(input.buffer(), "my draft");
547        assert_eq!(cursor(&input), 8);
548    }
549
550    #[tokio::test]
551    async fn down_on_live_prompt_does_nothing() {
552        let mut input = input_with_history(&["first"]);
553        let outcome = input.on_event(&key(KeyCode::Down)).await;
554        assert_eq!(input.buffer(), "");
555        assert!(outcome.is_some());
556    }
557
558    #[tokio::test]
559    async fn empty_history_up_does_nothing_special() {
560        let mut input = TextInput::default();
561        input.on_event(&key(KeyCode::Up)).await;
562        assert_eq!(input.buffer(), "");
563        assert_eq!(cursor(&input), 0);
564    }
565
566    #[tokio::test]
567    async fn typing_resets_history_navigation() {
568        let mut input = input_with_history(&["first", "second"]);
569        input.on_event(&key(KeyCode::Up)).await;
570        assert_eq!(input.buffer(), "second");
571
572        input.on_event(&key(KeyCode::Char('x'))).await;
573        assert_eq!(input.buffer(), "xsecond");
574
575        input.on_event(&key(KeyCode::Down)).await;
576        assert_eq!(cursor(&input), 7);
577    }
578
579    #[tokio::test]
580    async fn up_on_first_row_of_wrapped_text_navigates_history() {
581        let mut input = TextInput::default();
582        input.set_content_width(5);
583        input.record_submission("old entry");
584        input.set_input("hello world".to_string());
585        input.set_cursor_pos(3); // on row 0
586        input.on_event(&key(KeyCode::Up)).await;
587        assert_eq!(input.buffer(), "old entry");
588    }
589
590    #[tokio::test]
591    async fn up_on_middle_row_of_wrapped_text_keeps_normal_navigation() {
592        let mut input = TextInput::default();
593        input.set_content_width(5);
594        input.record_submission("old entry");
595        input.set_input("hello world".to_string());
596        input.set_cursor_pos(8);
597        input.on_event(&key(KeyCode::Up)).await;
598
599        assert_eq!(input.buffer(), "hello world");
600        assert_eq!(cursor(&input), 2);
601    }
602
603    #[tokio::test]
604    async fn down_on_last_row_of_recalled_single_line_navigates_history() {
605        let mut input = TextInput::default();
606        input.set_content_width(20);
607        input.record_submission("old entry");
608        input.record_submission("newer entry");
609        input.set_input("current draft".to_string());
610
611        input.on_event(&key(KeyCode::Up)).await;
612        assert_eq!(input.buffer(), "newer entry");
613
614        input.on_event(&key(KeyCode::Up)).await;
615        assert_eq!(input.buffer(), "old entry");
616
617        input.on_event(&key(KeyCode::Down)).await;
618        assert_eq!(input.buffer(), "newer entry");
619
620        input.on_event(&key(KeyCode::Down)).await;
621        assert_eq!(input.buffer(), "current draft");
622    }
623
624    #[tokio::test]
625    async fn down_on_non_last_row_of_wrapped_text_keeps_normal_navigation() {
626        let mut input = TextInput::default();
627        input.set_content_width(5);
628        input.set_input("hello world".to_string());
629        input.set_cursor_pos(3);
630        input.on_event(&key(KeyCode::Down)).await;
631
632        assert_eq!(input.buffer(), "hello world");
633        assert_eq!(cursor(&input), 9);
634    }
635
636    #[tokio::test]
637    async fn recalling_history_clears_mentions() {
638        let mut input = TextInput::default();
639        input.apply_file_selection(PathBuf::from("foo.rs"), "foo.rs".to_string());
640        assert_eq!(input.mentions().len(), 1);
641
642        input.record_submission("some prompt");
643        input.set_input(String::new());
644
645        input.on_event(&key(KeyCode::Up)).await;
646        assert_eq!(input.mentions().len(), 0);
647        assert_eq!(input.buffer(), "some prompt");
648    }
649
650    #[test]
651    fn record_submission_adds_to_history() {
652        let mut input = TextInput::default();
653        input.record_submission("first");
654        input.record_submission("second");
655        input.record_submission("third");
656        assert_eq!(input.history.prompts, vec!["first", "second", "third"]);
657    }
658}