vtcode_tui/core_tui/session/
editing.rs1use super::{InlinePromptSuggestionSource, Session};
2use crate::config::constants::ui;
3use unicode_segmentation::UnicodeSegmentation;
12use vtcode_vim::{next_char_boundary, prev_char_boundary};
13
14const WORD_SEPARATORS: &str = "`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?";
15
16fn is_word_separator(ch: char) -> bool {
17 WORD_SEPARATORS.contains(ch)
18}
19
20fn is_separator_piece(piece: &str) -> bool {
21 piece.chars().all(is_word_separator)
22}
23
24fn split_word_pieces(run: &str) -> Vec<(usize, &str)> {
25 let mut pieces = Vec::new();
26 for (segment_start, segment) in run.split_word_bound_indices() {
27 let mut piece_start = 0;
28 let mut chars = segment.char_indices();
29 let Some((_, first_char)) = chars.next() else {
30 continue;
31 };
32 let mut in_separator = is_word_separator(first_char);
33
34 for (idx, ch) in chars {
35 let is_separator = is_word_separator(ch);
36 if is_separator == in_separator {
37 continue;
38 }
39
40 pieces.push((segment_start + piece_start, &segment[piece_start..idx]));
41 piece_start = idx;
42 in_separator = is_separator;
43 }
44
45 pieces.push((segment_start + piece_start, &segment[piece_start..]));
46 }
47
48 pieces
49}
50
51fn previous_word_boundary(content: &str, cursor: usize) -> usize {
52 if cursor == 0 {
53 return 0;
54 }
55
56 let prefix = &content[..cursor];
57 let Some((first_non_ws_idx, ch)) = prefix
58 .char_indices()
59 .rev()
60 .find(|&(_, ch)| !ch.is_whitespace())
61 else {
62 return 0;
63 };
64
65 let run_start = prefix[..first_non_ws_idx]
66 .char_indices()
67 .rev()
68 .find(|&(_, ch)| ch.is_whitespace())
69 .map_or(0, |(idx, ch)| idx + ch.len_utf8());
70 let run_end = first_non_ws_idx + ch.len_utf8();
71 let pieces = split_word_pieces(&prefix[run_start..run_end]);
72 let mut pieces = pieces.into_iter().rev().peekable();
73 let Some((piece_start, piece)) = pieces.next() else {
74 return run_start;
75 };
76 let mut start = run_start + piece_start;
77
78 if is_separator_piece(piece) {
79 while let Some((idx, piece)) = pieces.peek() {
80 if !is_separator_piece(piece) {
81 break;
82 }
83 start = run_start + *idx;
84 pieces.next();
85 }
86 }
87
88 start
89}
90
91fn next_word_boundary(content: &str, cursor: usize) -> usize {
92 if cursor >= content.len() {
93 return content.len();
94 }
95
96 let suffix = &content[cursor..];
97 let Some(first_non_ws) = suffix.find(|ch: char| !ch.is_whitespace()) else {
98 return content.len();
99 };
100
101 let run = &suffix[first_non_ws..];
102 let run = &run[..run.find(char::is_whitespace).unwrap_or(run.len())];
103 let mut pieces = split_word_pieces(run).into_iter().peekable();
104 let Some((start, piece)) = pieces.next() else {
105 return cursor + first_non_ws;
106 };
107
108 let word_start = cursor + first_non_ws + start;
109 let mut end = word_start + piece.len();
110 if is_separator_piece(piece) {
111 while let Some((idx, piece)) = pieces.peek() {
112 if !is_separator_piece(piece) {
113 break;
114 }
115 end = cursor + first_non_ws + *idx + piece.len();
116 pieces.next();
117 }
118 }
119
120 end
121}
122
123impl Session {
124 pub(crate) fn refresh_input_edit_state(&mut self) {
125 self.clear_suggested_prompt_state();
126 self.clear_inline_prompt_suggestion();
127 self.input_compact_mode = self.input_compact_placeholder().is_some();
128 self.invalidate_header_cache();
129 }
130
131 pub(crate) fn set_inline_prompt_suggestion(&mut self, suggestion: String, llm_generated: bool) {
132 let trimmed = suggestion.trim();
133 if trimmed.is_empty() {
134 self.clear_inline_prompt_suggestion();
135 return;
136 }
137
138 self.inline_prompt_suggestion.suggestion = Some(trimmed.to_string());
139 self.inline_prompt_suggestion.source = Some(if llm_generated {
140 InlinePromptSuggestionSource::Llm
141 } else {
142 InlinePromptSuggestionSource::Local
143 });
144 self.mark_dirty();
145 }
146
147 pub(crate) fn accept_inline_prompt_suggestion(&mut self) -> bool {
148 let Some(suffix) = self.visible_inline_prompt_suggestion_suffix() else {
149 return false;
150 };
151
152 self.input_manager.insert_text(&suffix);
153 self.clear_inline_prompt_suggestion();
154 self.mark_dirty();
155 true
156 }
157
158 pub(crate) fn insert_char(&mut self, ch: char) {
160 if ch == '\u{7f}' {
161 return;
162 }
163 if ch == '\n' && !self.can_insert_newline() {
164 return;
165 }
166 self.input_manager.insert_char(ch);
167 self.refresh_input_edit_state();
168 }
169
170 pub fn insert_paste_text(&mut self, text: &str) {
176 let sanitized: String = text
177 .chars()
178 .filter(|&ch| ch != '\r' && ch != '\u{7f}')
179 .collect();
180
181 if sanitized.is_empty() {
182 return;
183 }
184
185 self.input_manager.insert_text(&sanitized);
186 self.refresh_input_edit_state();
187 }
188
189 pub(crate) fn apply_suggested_prompt(&mut self, text: String) {
190 let trimmed = text.trim();
191 if trimmed.is_empty() {
192 return;
193 }
194
195 let merged = if self.input_manager.content().trim().is_empty() {
196 trimmed.to_string()
197 } else {
198 let trimmed_end = self.input_manager.content().trim_end();
199 let cap = trimmed_end.len() + 2 + trimmed.len();
200 let mut s = String::with_capacity(cap);
201 s.push_str(trimmed_end);
202 s.push_str("\n\n");
203 s.push_str(trimmed);
204 s
205 };
206
207 self.input_manager.set_content(merged);
208 self.input_manager
209 .set_cursor(self.input_manager.content().len());
210 self.suggested_prompt_state.active = true;
211 self.input_compact_mode = self.input_compact_placeholder().is_some();
212 self.mark_dirty();
213 }
214
215 pub(crate) fn remaining_newline_capacity(&self) -> usize {
217 ui::INLINE_INPUT_MAX_LINES
218 .saturating_sub(1)
219 .saturating_sub(self.input_manager.content().matches('\n').count())
220 }
221
222 pub(crate) fn can_insert_newline(&self) -> bool {
224 self.remaining_newline_capacity() > 0
225 }
226
227 pub(crate) fn delete_char(&mut self) {
229 self.input_manager.backspace();
230 self.refresh_input_edit_state();
231 }
232
233 pub(crate) fn delete_char_forward(&mut self) {
235 self.input_manager.delete();
236 self.refresh_input_edit_state();
237 }
238
239 pub(crate) fn delete_word_backward(&mut self) {
241 if self.input_manager.delete_selection() {
242 self.refresh_input_edit_state();
243 return;
244 }
245 let cursor = self.input_manager.cursor();
246 if cursor == 0 {
247 return;
248 }
249
250 let delete_start = previous_word_boundary(self.input_manager.content(), cursor);
251
252 if delete_start < cursor {
253 self.input_manager.replace_range(delete_start, cursor, "");
254 self.input_manager.set_cursor(delete_start);
255 self.refresh_input_edit_state();
256 }
257 }
258
259 #[expect(dead_code)]
260 pub(crate) fn delete_word_forward(&mut self) {
261 self.input_manager.delete_word_forward();
262 self.refresh_input_edit_state();
263 }
264
265 pub(crate) fn delete_whitespace_around_cursor(&mut self) {
267 self.input_manager.delete_whitespace_around_cursor();
268 self.refresh_input_edit_state();
269 }
270
271 pub(crate) fn transpose_chars(&mut self) {
273 self.input_manager.transpose_chars();
274 self.refresh_input_edit_state();
275 }
276
277 pub(crate) fn transpose_words(&mut self) {
279 self.input_manager.transpose_words();
280 self.refresh_input_edit_state();
281 }
282
283 pub(crate) fn uppercase_word(&mut self) {
285 self.input_manager.uppercase_word();
286 self.refresh_input_edit_state();
287 }
288
289 pub(crate) fn lowercase_word(&mut self) {
291 self.input_manager.lowercase_word();
292 self.refresh_input_edit_state();
293 }
294
295 pub(crate) fn capitalize_word(&mut self) {
297 self.input_manager.capitalize_word();
298 self.refresh_input_edit_state();
299 }
300
301 pub(crate) fn delete_to_start_of_line(&mut self) {
303 if self.input_manager.delete_selection() {
304 self.refresh_input_edit_state();
305 return;
306 }
307 let content = self.input_manager.content();
308 let cursor = self.input_manager.cursor();
309
310 let before = &content[..cursor];
311 let delete_start = if let Some(newline_pos) = before.rfind('\n') {
312 newline_pos + 1
313 } else {
314 0
315 };
316
317 if delete_start < cursor {
318 self.input_manager.replace_range(delete_start, cursor, "");
319 self.input_manager.set_cursor(delete_start);
320 self.refresh_input_edit_state();
321 }
322 }
323
324 pub(crate) fn delete_to_end_of_line(&mut self) {
326 if self.input_manager.delete_selection() {
327 self.refresh_input_edit_state();
328 return;
329 }
330 let content = self.input_manager.content();
331 let cursor = self.input_manager.cursor();
332
333 let rest = &content[cursor..];
334 let delete_len = if let Some(newline_pos) = rest.find('\n') {
335 newline_pos
336 } else {
337 rest.len()
338 };
339
340 if delete_len > 0 {
341 self.input_manager
342 .replace_range(cursor, cursor + delete_len, "");
343 self.refresh_input_edit_state();
344 }
345 }
346
347 pub(crate) fn move_left(&mut self) {
349 self.input_manager.move_cursor_left();
350 }
351
352 pub(crate) fn move_right(&mut self) {
354 self.input_manager.move_cursor_right();
355 }
356
357 pub(crate) fn select_left(&mut self) {
358 let cursor = self.input_manager.cursor();
359 let content = self.input_manager.content();
360 let pos = prev_char_boundary(content, cursor);
361 self.input_manager.set_cursor_with_selection(pos);
362 }
363
364 pub(crate) fn select_right(&mut self) {
365 let cursor = self.input_manager.cursor();
366 let content = self.input_manager.content();
367 let pos = next_char_boundary(content, cursor);
368 self.input_manager.set_cursor_with_selection(pos);
369 }
370
371 pub(crate) fn move_left_word(&mut self) {
373 let cursor =
374 previous_word_boundary(self.input_manager.content(), self.input_manager.cursor());
375 self.input_manager.set_cursor(cursor);
376 }
377 pub(crate) fn move_right_word(&mut self) {
379 let cursor = next_word_boundary(self.input_manager.content(), self.input_manager.cursor());
380 self.input_manager.set_cursor(cursor);
381 }
382 pub(crate) fn move_to_start(&mut self) {
384 self.input_manager.move_cursor_to_start();
385 }
386
387 pub(crate) fn move_to_end(&mut self) {
389 self.input_manager.move_cursor_to_end();
390 }
391
392 pub(crate) fn select_to_start(&mut self) {
393 self.input_manager.set_cursor_with_selection(0);
394 }
395
396 pub(crate) fn select_to_end(&mut self) {
397 self.input_manager
398 .set_cursor_with_selection(self.input_manager.content().len());
399 }
400
401 pub(crate) fn remember_submitted_input(
403 &mut self,
404 submitted: super::input_manager::InputHistoryEntry,
405 ) {
406 self.input_manager.add_to_history(submitted);
407 }
408
409 pub(crate) fn navigate_history_previous(&mut self) -> bool {
411 if let Some(previous) = self.input_manager.go_to_previous_history() {
412 self.input_manager.apply_history_entry(previous);
413 true
414 } else {
415 false
416 }
417 }
418
419 pub(crate) fn navigate_history_next(&mut self) -> bool {
421 if let Some(next) = self.input_manager.go_to_next_history() {
422 self.input_manager.apply_history_entry(next);
423 true
424 } else {
425 false
426 }
427 }
428
429 pub fn history_position(&self) -> Option<(usize, usize)> {
432 self.input_manager.history_index().map(|idx| {
433 let total = self.input_manager.history().len();
434 (total - idx, total)
435 })
436 }
437}