vtcode_ui/tui/core_tui/session/
editing.rs1use super::{InlinePromptSuggestionSource, Session};
2use crate::tui::config::constants::ui;
3use crate::vim::{next_char_boundary, prev_char_boundary};
4use unicode_segmentation::UnicodeSegmentation;
13
14const WORD_SEPARATORS: &str = "`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?";
15
16fn is_word_separator(ch: char) -> bool {
17 WORD_SEPARATORS.contains(ch)
18}
19
20fn sanitize_pasted_text(text: &str) -> String {
23 text.chars().filter(|&ch| ch != '\r' && ch != '\u{7f}').collect()
24}
25
26fn is_separator_piece(piece: &str) -> bool {
27 piece.chars().all(is_word_separator)
28}
29
30fn split_word_pieces(run: &str) -> Vec<(usize, &str)> {
31 let mut pieces = Vec::new();
32 for (segment_start, segment) in run.split_word_bound_indices() {
33 let mut piece_start = 0;
34 let mut chars = segment.char_indices();
35 let Some((_, first_char)) = chars.next() else {
36 continue;
37 };
38 let mut in_separator = is_word_separator(first_char);
39
40 for (idx, ch) in chars {
41 let is_separator = is_word_separator(ch);
42 if is_separator == in_separator {
43 continue;
44 }
45
46 pieces.push((segment_start + piece_start, &segment[piece_start..idx]));
47 piece_start = idx;
48 in_separator = is_separator;
49 }
50
51 pieces.push((segment_start + piece_start, &segment[piece_start..]));
52 }
53
54 pieces
55}
56
57fn previous_word_boundary(content: &str, cursor: usize) -> usize {
58 if cursor == 0 {
59 return 0;
60 }
61
62 let prefix = &content[..cursor];
63 let Some((first_non_ws_idx, ch)) = prefix.char_indices().rev().find(|&(_, ch)| !ch.is_whitespace()) else {
64 return 0;
65 };
66
67 let run_start = prefix[..first_non_ws_idx]
68 .char_indices()
69 .rev()
70 .find(|&(_, ch)| ch.is_whitespace())
71 .map_or(0, |(idx, ch)| idx + ch.len_utf8());
72 let run_end = first_non_ws_idx + ch.len_utf8();
73 let pieces = split_word_pieces(&prefix[run_start..run_end]);
74 let mut pieces = pieces.into_iter().rev().peekable();
75 let Some((piece_start, piece)) = pieces.next() else {
76 return run_start;
77 };
78 let mut start = run_start + piece_start;
79
80 if is_separator_piece(piece) {
81 while let Some((idx, piece)) = pieces.peek() {
82 if !is_separator_piece(piece) {
83 break;
84 }
85 start = run_start + *idx;
86 pieces.next();
87 }
88 }
89
90 start
91}
92
93fn next_word_boundary(content: &str, cursor: usize) -> usize {
94 if cursor >= content.len() {
95 return content.len();
96 }
97
98 let suffix = &content[cursor..];
99 let Some(first_non_ws) = suffix.find(|ch: char| !ch.is_whitespace()) else {
100 return content.len();
101 };
102
103 let run = &suffix[first_non_ws..];
104 let run = &run[..run.find(char::is_whitespace).unwrap_or(run.len())];
105 let mut pieces = split_word_pieces(run).into_iter().peekable();
106 let Some((start, piece)) = pieces.next() else {
107 return cursor + first_non_ws;
108 };
109
110 let word_start = cursor + first_non_ws + start;
111 let mut end = word_start + piece.len();
112 if is_separator_piece(piece) {
113 while let Some((idx, piece)) = pieces.peek() {
114 if !is_separator_piece(piece) {
115 break;
116 }
117 end = cursor + first_non_ws + *idx + piece.len();
118 pieces.next();
119 }
120 }
121
122 end
123}
124
125impl Session {
126 pub(crate) fn refresh_input_edit_state(&mut self) {
127 self.clear_suggested_prompt_state();
128 self.clear_inline_prompt_suggestion();
129 self.input_compact_mode = self.input_compact_placeholder().is_some();
130 self.invalidate_header_cache();
131 }
132
133 pub(crate) fn set_inline_prompt_suggestion(&mut self, suggestion: String, llm_generated: bool) {
134 let trimmed = suggestion.trim();
135 if trimmed.is_empty() {
136 self.clear_inline_prompt_suggestion();
137 return;
138 }
139
140 self.inline_prompt_suggestion.suggestion = Some(trimmed.to_string());
141 self.inline_prompt_suggestion.source = Some(if llm_generated {
142 InlinePromptSuggestionSource::Llm
143 } else {
144 InlinePromptSuggestionSource::Local
145 });
146 self.mark_dirty();
147 }
148
149 pub(crate) fn accept_inline_prompt_suggestion(&mut self) -> bool {
150 let Some(suffix) = self.visible_inline_prompt_suggestion_suffix() else {
151 return false;
152 };
153
154 self.input_manager.insert_text(&suffix);
155 self.clear_inline_prompt_suggestion();
156 self.mark_dirty();
157 true
158 }
159
160 #[cfg_attr(feature = "profiling", hotpath::measure)]
162 pub(crate) fn insert_char(&mut self, ch: char) {
163 if ch == '\u{7f}' {
164 return;
165 }
166 if ch == '\n' && !self.can_insert_newline() {
167 return;
168 }
169 self.input_manager.insert_char(ch);
170 self.refresh_input_edit_state();
171 }
172
173 pub(crate) fn insert_paste_text(&mut self, text: &str) {
183 let sanitized = sanitize_pasted_text(text);
184
185 if sanitized.is_empty() {
186 return;
187 }
188
189 let was_collapsed = self.input_compact_mode;
190 let paste_start = self
191 .input_manager
192 .selection_range()
193 .map_or_else(|| self.input_manager.cursor(), |(start, _)| start);
194 let paste_end = paste_start.saturating_add(sanitized.len());
195 let should_collapse = super::input::should_track_compact_paste(&sanitized);
196 self.input_manager.insert_text(&sanitized);
197 if should_collapse {
198 self.input_manager.set_compact_paste_range(paste_start..paste_end);
199 }
200 self.refresh_input_edit_state();
201 if was_collapsed && self.input_compact_placeholder().is_some() {
202 self.input_compact_mode = false;
203 }
204 }
205
206 pub(crate) fn insert_raw_paste_text(&mut self, text: &str) {
212 let sanitized = sanitize_pasted_text(text);
213
214 if sanitized.is_empty() {
215 return;
216 }
217
218 self.input_manager.insert_text(&sanitized);
219 self.refresh_input_edit_state();
220 self.input_compact_mode = false;
221 }
222
223 pub(crate) fn apply_suggested_prompt(&mut self, text: String) {
224 let trimmed = text.trim();
225 if trimmed.is_empty() {
226 return;
227 }
228
229 let merged = if self.input_manager.content().trim().is_empty() {
230 trimmed.to_string()
231 } else {
232 let trimmed_end = self.input_manager.content().trim_end();
233 let cap = trimmed_end.len() + 2 + trimmed.len();
234 let mut s = String::with_capacity(cap);
235 s.push_str(trimmed_end);
236 s.push_str("\n\n");
237 s.push_str(trimmed);
238 s
239 };
240
241 self.input_manager.set_content(merged);
242 self.input_manager.set_cursor(self.input_manager.content().len());
243 self.suggested_prompt_state.active = true;
244 self.input_compact_mode = self.input_compact_placeholder().is_some();
245 self.mark_dirty();
246 }
247
248 fn remaining_newline_capacity(&self) -> usize {
250 let content = self.input_manager.content();
251 let mut newline_count = content.matches('\n').count();
252 if let Some(range) = self.input_manager.compact_paste_range()
253 && range.end <= content.len()
254 && content.is_char_boundary(range.start)
255 && content.is_char_boundary(range.end)
256 {
257 newline_count = newline_count.saturating_sub(content[range].matches('\n').count());
258 }
259
260 ui::INLINE_INPUT_MAX_LINES.saturating_sub(1).saturating_sub(newline_count)
261 }
262
263 fn can_insert_newline(&self) -> bool {
265 self.remaining_newline_capacity() > 0
266 }
267
268 pub(crate) fn delete_char(&mut self) {
270 if self.input_compact_mode
277 && let Some(range) = self.input_manager.compact_paste_range()
278 && range.end > range.start
279 && self.input_manager.selection_range().is_none()
280 && self.input_manager.cursor() == range.end
281 {
282 let content = self.input_manager.content();
283 if content.is_char_boundary(range.start) && content.is_char_boundary(range.end) {
284 self.input_manager.replace_range(range.start, range.end, "");
285 self.refresh_input_edit_state();
286 return;
287 }
288 }
289 self.input_manager.backspace();
290 self.refresh_input_edit_state();
291 }
292
293 pub(crate) fn delete_char_forward(&mut self) {
295 self.input_manager.delete();
296 self.refresh_input_edit_state();
297 }
298
299 pub(crate) fn delete_word_backward(&mut self) {
301 if self.input_manager.delete_selection() {
302 self.refresh_input_edit_state();
303 return;
304 }
305 let cursor = self.input_manager.cursor();
306 if cursor == 0 {
307 return;
308 }
309
310 let delete_start = previous_word_boundary(self.input_manager.content(), cursor);
311
312 if delete_start < cursor {
313 self.input_manager.replace_range(delete_start, cursor, "");
314 self.input_manager.set_cursor(delete_start);
315 self.refresh_input_edit_state();
316 }
317 }
318
319 pub(crate) fn delete_word_forward(&mut self) {
320 self.input_manager.delete_word_forward();
321 self.refresh_input_edit_state();
322 }
323
324 pub(crate) fn delete_whitespace_around_cursor(&mut self) {
326 self.input_manager.delete_whitespace_around_cursor();
327 self.refresh_input_edit_state();
328 }
329
330 pub(crate) fn transpose_chars(&mut self) {
332 self.input_manager.transpose_chars();
333 self.refresh_input_edit_state();
334 }
335
336 pub(crate) fn transpose_words(&mut self) {
338 self.input_manager.transpose_words();
339 self.refresh_input_edit_state();
340 }
341
342 pub(crate) fn uppercase_word(&mut self) {
344 self.input_manager.uppercase_word();
345 self.refresh_input_edit_state();
346 }
347
348 pub(crate) fn lowercase_word(&mut self) {
350 self.input_manager.lowercase_word();
351 self.refresh_input_edit_state();
352 }
353
354 pub(crate) fn capitalize_word(&mut self) {
356 self.input_manager.capitalize_word();
357 self.refresh_input_edit_state();
358 }
359
360 pub(crate) fn delete_to_end_of_line(&mut self) {
362 if self.input_manager.delete_selection() {
363 self.refresh_input_edit_state();
364 return;
365 }
366 let content = self.input_manager.content();
367 let cursor = self.input_manager.cursor();
368
369 let rest = &content[cursor..];
370 let delete_len = if let Some(newline_pos) = rest.find('\n') {
371 newline_pos
372 } else {
373 rest.len()
374 };
375
376 if delete_len > 0 {
377 self.input_manager.replace_range(cursor, cursor + delete_len, "");
378 self.refresh_input_edit_state();
379 }
380 }
381
382 pub(crate) fn move_left(&mut self) {
384 self.input_manager.move_cursor_left();
385 }
386
387 pub(crate) fn move_right(&mut self) {
389 self.input_manager.move_cursor_right();
390 }
391
392 pub(crate) fn select_left(&mut self) {
393 let cursor = self.input_manager.cursor();
394 let content = self.input_manager.content();
395 let pos = prev_char_boundary(content, cursor);
396 self.input_manager.set_cursor_with_selection(pos);
397 }
398
399 pub(crate) fn select_right(&mut self) {
400 let cursor = self.input_manager.cursor();
401 let content = self.input_manager.content();
402 let pos = next_char_boundary(content, cursor);
403 self.input_manager.set_cursor_with_selection(pos);
404 }
405
406 pub(crate) fn move_left_word(&mut self) {
408 let cursor = previous_word_boundary(self.input_manager.content(), self.input_manager.cursor());
409 self.input_manager.set_cursor(cursor);
410 }
411 pub(crate) fn move_right_word(&mut self) {
413 let cursor = next_word_boundary(self.input_manager.content(), self.input_manager.cursor());
414 self.input_manager.set_cursor(cursor);
415 }
416 pub(crate) fn move_to_start(&mut self) {
418 self.input_manager.move_cursor_to_start();
419 }
420
421 pub(crate) fn move_to_end(&mut self) {
423 self.input_manager.move_cursor_to_end();
424 }
425
426 pub(crate) fn move_to_start_of_line(&mut self) {
431 self.input_manager.move_cursor_to_start_of_line();
432 }
433
434 pub(crate) fn move_to_end_of_line(&mut self) {
439 self.input_manager.move_cursor_to_end_of_line();
440 }
441
442 pub(crate) fn select_to_start(&mut self) {
443 self.input_manager.set_cursor_with_selection(0);
444 }
445
446 pub(crate) fn select_to_end(&mut self) {
447 self.input_manager.set_cursor_with_selection(self.input_manager.content().len());
448 }
449
450 pub(crate) fn select_to_start_of_line(&mut self) {
452 let (line_start, _) = self.input_manager.current_line_byte_range();
453 self.input_manager.set_cursor_with_selection(line_start);
454 }
455
456 pub(crate) fn select_to_end_of_line(&mut self) {
458 let (_, line_end) = self.input_manager.current_line_byte_range();
459 self.input_manager.set_cursor_with_selection(line_end);
460 }
461
462 pub(crate) fn clear_current_line_or_all(&mut self) {
474 if self.input_manager.delete_selection() {
475 self.refresh_input_edit_state();
476 return;
477 }
478 let content_len = self.input_manager.content().len();
479 if content_len == 0 {
480 return;
481 }
482 if self.input_manager.is_single_line() {
483 self.input_manager.clear();
484 self.refresh_input_edit_state();
485 return;
486 }
487
488 let cursor = self.input_manager.cursor().min(content_len);
489 let (line_start, line_end) = self.input_manager.current_line_byte_range();
490 let mut clear_start = line_start.min(content_len);
491 let mut clear_end = line_end.min(content_len);
492
493 if self.input_compact_mode
494 && let Some(range) = self.input_manager.compact_paste_range()
495 && range.start < range.end
496 {
497 let range_start = range.start.min(content_len);
499 let range_end = range.end.min(content_len);
500 if range_start < range_end {
501 let overlaps_line = range_start < clear_end && clear_start < range_end;
502 let cursor_inside = cursor >= range_start && cursor <= range_end;
503 if overlaps_line || cursor_inside {
504 clear_start = clear_start.min(range_start);
505 clear_end = clear_end.max(range_end);
506 }
507 }
508 }
509
510 let content = self.input_manager.content();
512 while clear_start > 0 && !content.is_char_boundary(clear_start) {
513 clear_start -= 1;
514 }
515 while clear_end < content.len() && !content.is_char_boundary(clear_end) {
516 clear_end += 1;
517 }
518 if clear_start >= clear_end {
519 return;
520 }
521 self.input_manager.replace_range(clear_start, clear_end, "");
522 self.input_manager.set_cursor(clear_start);
523 self.refresh_input_edit_state();
524 }
525
526 pub(crate) fn remember_submitted_input(&mut self, submitted: super::input_manager::InputHistoryEntry) {
528 self.input_manager.add_to_history(submitted);
529 }
530
531 pub(crate) fn navigate_history_previous(&mut self) -> bool {
533 if let Some(previous) = self.input_manager.go_to_previous_history() {
534 self.input_manager.apply_history_entry(previous);
535 self.input_compact_mode = self.input_compact_placeholder().is_some();
536 true
537 } else {
538 false
539 }
540 }
541
542 pub(crate) fn navigate_history_next(&mut self) -> bool {
544 if let Some(next) = self.input_manager.go_to_next_history() {
545 self.input_manager.apply_history_entry(next);
546 self.input_compact_mode = self.input_compact_placeholder().is_some();
547 true
548 } else {
549 false
550 }
551 }
552
553 pub(crate) fn move_cursor_up_for_history(&mut self) -> bool {
562 if !self.input_enabled {
563 return false;
564 }
565 self.clear_inline_prompt_suggestion();
566 self.input_manager.move_cursor_up()
567 }
568
569 pub(crate) fn move_cursor_down_for_history(&mut self) -> bool {
578 if !self.input_enabled {
579 return false;
580 }
581 self.clear_inline_prompt_suggestion();
582 self.input_manager.move_cursor_down()
583 }
584
585 pub(crate) fn move_up_within_composer(&mut self) -> bool {
592 if !self.input_enabled {
593 return false;
594 }
595 self.clear_inline_prompt_suggestion();
596 if self.input_visual_geometry().is_some() {
597 self.move_cursor_up_within_visual()
598 } else {
599 self.input_manager.move_cursor_up()
600 }
601 }
602
603 pub(crate) fn move_down_within_composer(&mut self) -> bool {
610 if !self.input_enabled {
611 return false;
612 }
613 self.clear_inline_prompt_suggestion();
614 if self.input_visual_geometry().is_some() {
615 self.move_cursor_down_within_visual()
616 } else {
617 self.input_manager.move_cursor_down()
618 }
619 }
620
621 pub fn history_position(&self) -> Option<(usize, usize)> {
624 self.input_manager.history_index().map(|idx| {
625 let total = self.input_manager.history().len();
626 (total - idx, total)
627 })
628 }
629}