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() && 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 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 let mut input = input_with("a中b", None);
347
348 let steps: &[(KeyCode, usize)] = &[
349 (KeyCode::Left, 4), (KeyCode::Left, 1), (KeyCode::Left, 0), (KeyCode::Right, 1), (KeyCode::Right, 4), ];
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 assert_eq!(input.cursor_index(Some(2)), 3); }
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 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); 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); input.on_event(&key(KeyCode::Up)).await;
566
567 assert_eq!(input.buffer(), "hello world");
568 assert_eq!(cursor(&input), 3); }
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); input.on_event(&key(KeyCode::Down)).await;
599
600 assert_eq!(input.buffer(), "hello world");
601 assert_eq!(cursor(&input), 8); }
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}