Skip to main content

vtcode_ui/tui/core_tui/session/
editing.rs

1use super::{InlinePromptSuggestionSource, Session};
2use crate::tui::config::constants::ui;
3use crate::vim::{next_char_boundary, prev_char_boundary};
4/// Text editing and cursor movement operations for Session
5///
6/// This module handles all text manipulation and cursor navigation including:
7/// - Character insertion and deletion
8/// - Word and sentence-level editing
9/// - Cursor movement (character, word, line boundaries)
10/// - Input history navigation
11/// - Newline handling with capacity limits
12use unicode_segmentation::UnicodeSegmentation;
13
14const WORD_SEPARATORS: &str = "`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?";
15
16fn is_word_separator(ch: char) -> bool {
17    WORD_SEPARATORS.contains(ch)
18}
19
20/// Strip carriage returns and DEL bytes from pasted text so terminal and
21/// clipboard pastes land verbatim without control-code side effects.
22fn 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    /// Insert a character at the current cursor position
161    #[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    /// Insert pasted text without enforcing the inline newline cap.
174    ///
175    /// This preserves the full block (including large multi-line pastes) so the
176    /// agent receives the exact content instead of dropping line breaks after
177    /// hitting the interactive input's visual limit. Large pastes — by lines,
178    /// chars, images, or file tokens — are tracked as a collapsible block so
179    /// the composer summarizes previous content instead of showing full text.
180    /// Pasting while already collapsed expands to the full text instead, so
181    /// paste-once summarizes and paste-twice reviews the complete content.
182    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    /// Insert pasted text verbatim, bypassing collapse tracking.
207    ///
208    /// Shift+Ctrl+V pastes clipboard text as raw full content: no compact
209    /// block is tracked and the composer stays expanded so nothing is
210    /// summarized away, no matter how large the pasted text is.
211    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    /// Calculate remaining newline capacity in the input field
249    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    /// Check if a newline can be inserted
264    fn can_insert_newline(&self) -> bool {
265        self.remaining_newline_capacity() > 0
266    }
267
268    /// Delete the character before the cursor (backspace)
269    pub(crate) fn delete_char(&mut self) {
270        // One Backspace right after a collapsed multi-line paste removes the
271        // whole inserted block instead of peeling single characters. This only
272        // applies while collapsed: in the expanded full-text view Backspace
273        // peels one character like normal multiline editing. When the
274        // user has an active selection, the selection must win (it removes
275        // exactly what the user highlighted).
276        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    /// Delete the character at the cursor (forward delete)
294    pub(crate) fn delete_char_forward(&mut self) {
295        self.input_manager.delete();
296        self.refresh_input_edit_state();
297    }
298
299    /// Delete the word before the cursor
300    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    /// Delete whitespace around the cursor (Readline Alt+\)
325    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    /// Transpose characters at cursor position (Readline Ctrl+T)
331    pub(crate) fn transpose_chars(&mut self) {
332        self.input_manager.transpose_chars();
333        self.refresh_input_edit_state();
334    }
335
336    /// Transpose words at cursor position (Readline Alt+T)
337    pub(crate) fn transpose_words(&mut self) {
338        self.input_manager.transpose_words();
339        self.refresh_input_edit_state();
340    }
341
342    /// Uppercase the current word (Readline Alt+U)
343    pub(crate) fn uppercase_word(&mut self) {
344        self.input_manager.uppercase_word();
345        self.refresh_input_edit_state();
346    }
347
348    /// Lowercase the current word (Readline Alt+L)
349    pub(crate) fn lowercase_word(&mut self) {
350        self.input_manager.lowercase_word();
351        self.refresh_input_edit_state();
352    }
353
354    /// Capitalize the current word (Readline Alt+C)
355    pub(crate) fn capitalize_word(&mut self) {
356        self.input_manager.capitalize_word();
357        self.refresh_input_edit_state();
358    }
359
360    /// Delete from cursor to end of current line (Command+Delete on macOS)
361    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    /// Move cursor left by one character
383    pub(crate) fn move_left(&mut self) {
384        self.input_manager.move_cursor_left();
385    }
386
387    /// Move cursor right by one character
388    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    /// Move cursor left to the start of the previous word
407    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    /// Move cursor right to the start of the next word
412    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    /// Move cursor to the start of the line
417    pub(crate) fn move_to_start(&mut self) {
418        self.input_manager.move_cursor_to_start();
419    }
420
421    /// Move cursor to the end of the line
422    pub(crate) fn move_to_end(&mut self) {
423        self.input_manager.move_cursor_to_end();
424    }
425
426    /// Move cursor to the beginning of the current logical line.
427    ///
428    /// Multi-line input moves within the cursor's line; single-line input
429    /// degenerates to buffer start via [`InputManager::move_cursor_to_start_of_line`].
430    pub(crate) fn move_to_start_of_line(&mut self) {
431        self.input_manager.move_cursor_to_start_of_line();
432    }
433
434    /// Move cursor to the end of the current logical line.
435    ///
436    /// Multi-line input moves within the cursor's line; single-line input
437    /// degenerates to buffer end via [`InputManager::move_cursor_to_end_of_line`].
438    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    /// Extend selection to the beginning of the current logical line.
451    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    /// Extend selection to the end of the current logical line.
457    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    /// Clear the current logical line, or the entire input when single-line.
463    ///
464    /// Dimension key: `line_start..line_end` are byte offsets of the cursor's
465    /// logical line; `clear_start..clear_end` expands that range to cover an
466    /// overlapping compact paste block (`[Pasted Content N chars]`) so a
467    /// collapsed multi-line paste is removed atomically. The expansion only
468    /// applies while collapsed; in the expanded full-text view line clears
469    /// behave like normal multiline editing. Image placeholders
470    /// (`[Image #N]`) live inside their line, so line deletion already covers
471    /// them; single-line clears go through [`InputManager::clear`] which also
472    /// drops attachments and compact state.
473    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            // Clamp stale ranges to current content before expanding.
498            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        // Clamp to char boundaries so multi-byte content never panics.
511        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    /// Remember submitted input in history
527    pub(crate) fn remember_submitted_input(&mut self, submitted: super::input_manager::InputHistoryEntry) {
528        self.input_manager.add_to_history(submitted);
529    }
530
531    /// Navigate to previous history entry (disabled to prevent cursor flickering)
532    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    /// Navigate to next history entry (disabled to prevent cursor flickering)
543    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    /// Arrow-Up handling for single-row history gating.
554    ///
555    /// Returns `true` when the key was consumed by an intra-composer cursor
556    /// move (caller should `mark_dirty()` and emit no history event).
557    /// Movement is by logical lines; wrapped visual rows are handled by
558    /// [`Session::move_up_within_composer`]. The events layer consumes the
559    /// key for all multi-row input, so a `false` return only reaches history
560    /// traversal for single-row input.
561    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    /// Arrow-Down handling for single-row history gating.
570    ///
571    /// Returns `true` when the key was consumed by an intra-composer cursor
572    /// move (caller should `mark_dirty()` and emit no history event).
573    /// Movement is by logical lines; wrapped visual rows are handled by
574    /// [`Session::move_down_within_composer`]. The events layer consumes the
575    /// key for all multi-row input, so a `false` return only reaches history
576    /// traversal for single-row input.
577    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    /// Arrow-Up movement within a multi-row composer, honoring soft wraps.
586    ///
587    /// Uses visual-row movement when the input area is known, logical-line
588    /// movement before the first render. Returns `true` when the cursor
589    /// moved; the events layer consumes the key for multi-row input even
590    /// when already at the edge (returns `false` there).
591    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    /// Arrow-Down movement within a multi-row composer, honoring soft wraps.
604    ///
605    /// Uses visual-row movement when the input area is known, logical-line
606    /// movement before the first render. Returns `true` when the cursor
607    /// moved; the events layer consumes the key for multi-row input even
608    /// when already at the edge (returns `false` there).
609    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    /// Returns the current history position for status bar display
622    /// Returns (current_index, total_entries) or None if not navigating history
623    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}