Skip to main content

vtcode_tui/core_tui/session/
editing.rs

1use super::{InlinePromptSuggestionSource, Session};
2use crate::config::constants::ui;
3/// Text editing and cursor movement operations for Session
4///
5/// This module handles all text manipulation and cursor navigation including:
6/// - Character insertion and deletion
7/// - Word and sentence-level editing
8/// - Cursor movement (character, word, line boundaries)
9/// - Input history navigation
10/// - Newline handling with capacity limits
11use 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    /// Insert a character at the current cursor position
159    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    /// Insert pasted text without enforcing the inline newline cap.
171    ///
172    /// This preserves the full block (including large multi-line pastes) so the
173    /// agent receives the exact content instead of dropping line breaks after
174    /// hitting the interactive input's visual limit.
175    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    /// Calculate remaining newline capacity in the input field
216    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    /// Check if a newline can be inserted
223    pub(crate) fn can_insert_newline(&self) -> bool {
224        self.remaining_newline_capacity() > 0
225    }
226
227    /// Delete the character before the cursor (backspace)
228    pub(crate) fn delete_char(&mut self) {
229        self.input_manager.backspace();
230        self.refresh_input_edit_state();
231    }
232
233    /// Delete the character at the cursor (forward delete)
234    pub(crate) fn delete_char_forward(&mut self) {
235        self.input_manager.delete();
236        self.refresh_input_edit_state();
237    }
238
239    /// Delete the word before the cursor
240    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    /// Delete whitespace around the cursor (Readline Alt+\)
266    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    /// Transpose characters at cursor position (Readline Ctrl+T)
272    pub(crate) fn transpose_chars(&mut self) {
273        self.input_manager.transpose_chars();
274        self.refresh_input_edit_state();
275    }
276
277    /// Transpose words at cursor position (Readline Alt+T)
278    pub(crate) fn transpose_words(&mut self) {
279        self.input_manager.transpose_words();
280        self.refresh_input_edit_state();
281    }
282
283    /// Uppercase the current word (Readline Alt+U)
284    pub(crate) fn uppercase_word(&mut self) {
285        self.input_manager.uppercase_word();
286        self.refresh_input_edit_state();
287    }
288
289    /// Lowercase the current word (Readline Alt+L)
290    pub(crate) fn lowercase_word(&mut self) {
291        self.input_manager.lowercase_word();
292        self.refresh_input_edit_state();
293    }
294
295    /// Capitalize the current word (Readline Alt+C)
296    pub(crate) fn capitalize_word(&mut self) {
297        self.input_manager.capitalize_word();
298        self.refresh_input_edit_state();
299    }
300
301    /// Delete from cursor to start of current line (Command+Backspace on macOS)
302    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    /// Delete from cursor to end of current line (Command+Delete on macOS)
325    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    /// Move cursor left by one character
348    pub(crate) fn move_left(&mut self) {
349        self.input_manager.move_cursor_left();
350    }
351
352    /// Move cursor right by one character
353    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    /// Move cursor left to the start of the previous word
372    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    /// Move cursor right to the start of the next word
378    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    /// Move cursor to the start of the line
383    pub(crate) fn move_to_start(&mut self) {
384        self.input_manager.move_cursor_to_start();
385    }
386
387    /// Move cursor to the end of the line
388    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    /// Remember submitted input in history
402    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    /// Navigate to previous history entry (disabled to prevent cursor flickering)
410    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    /// Navigate to next history entry (disabled to prevent cursor flickering)
420    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    /// Returns the current history position for status bar display
430    /// Returns (current_index, total_entries) or None if not navigating history
431    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}