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