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 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 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 let mut input = input_with("a中b", None);
351
352 let steps: &[(KeyCode, usize)] = &[
353 (KeyCode::Left, 4), (KeyCode::Left, 1), (KeyCode::Left, 0), (KeyCode::Right, 1), (KeyCode::Right, 4), ];
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 assert_eq!(input.cursor_index(Some(2)), 3); }
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 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); 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); input.on_event(&key(KeyCode::Up)).await;
570
571 assert_eq!(input.buffer(), "hello world");
572 assert_eq!(cursor(&input), 3); }
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); input.on_event(&key(KeyCode::Down)).await;
603
604 assert_eq!(input.buffer(), "hello world");
605 assert_eq!(cursor(&input), 8); }
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}