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