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 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 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 let mut input = input_with("a中b", None);
366
367 let steps: &[(KeyCode, usize)] = &[
368 (KeyCode::Left, 4), (KeyCode::Left, 1), (KeyCode::Left, 0), (KeyCode::Right, 1), (KeyCode::Right, 4), ];
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 assert_eq!(input.cursor_index(Some(2)), 3); }
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); 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}