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