Skip to main content

wisp/components/
text_input.rs

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