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    pub(crate) fn insert_char(&mut self, ch: char) {
162        if ch == '\u{7f}' {
163            return;
164        }
165        if ch == '\n' && !self.can_insert_newline() {
166            return;
167        }
168        self.input_manager.insert_char(ch);
169        self.refresh_input_edit_state();
170    }
171
172    /// Insert pasted text without enforcing the inline newline cap.
173    ///
174    /// This preserves the full block (including large multi-line pastes) so the
175    /// agent receives the exact content instead of dropping line breaks after
176    /// hitting the interactive input's visual limit. Large pastes — by lines,
177    /// chars, images, or file tokens — are tracked as a collapsible block so
178    /// the composer summarizes previous content instead of showing full text.
179    /// Pasting while already collapsed expands to the full text instead, so
180    /// paste-once summarizes and paste-twice reviews the complete content.
181    pub(crate) fn insert_paste_text(&mut self, text: &str) {
182        let sanitized = sanitize_pasted_text(text);
183
184        if sanitized.is_empty() {
185            return;
186        }
187
188        let was_collapsed = self.input_compact_mode;
189        let paste_start = self
190            .input_manager
191            .selection_range()
192            .map_or_else(|| self.input_manager.cursor(), |(start, _)| start);
193        let paste_end = paste_start.saturating_add(sanitized.len());
194        let should_collapse = super::input::should_track_compact_paste(&sanitized);
195        self.input_manager.insert_text(&sanitized);
196        if should_collapse {
197            self.input_manager.set_compact_paste_range(paste_start..paste_end);
198        }
199        self.refresh_input_edit_state();
200        if was_collapsed && self.input_compact_placeholder().is_some() {
201            self.input_compact_mode = false;
202        }
203    }
204
205    /// Insert pasted text verbatim, bypassing collapse tracking.
206    ///
207    /// Shift+Ctrl+V pastes clipboard text as raw full content: no compact
208    /// block is tracked and the composer stays expanded so nothing is
209    /// summarized away, no matter how large the pasted text is.
210    pub(crate) fn insert_raw_paste_text(&mut self, text: &str) {
211        let sanitized = sanitize_pasted_text(text);
212
213        if sanitized.is_empty() {
214            return;
215        }
216
217        self.input_manager.insert_text(&sanitized);
218        self.refresh_input_edit_state();
219        self.input_compact_mode = false;
220    }
221
222    pub(crate) fn apply_suggested_prompt(&mut self, text: String) {
223        let trimmed = text.trim();
224        if trimmed.is_empty() {
225            return;
226        }
227
228        let merged = if self.input_manager.content().trim().is_empty() {
229            trimmed.to_string()
230        } else {
231            let trimmed_end = self.input_manager.content().trim_end();
232            let cap = trimmed_end.len() + 2 + trimmed.len();
233            let mut s = String::with_capacity(cap);
234            s.push_str(trimmed_end);
235            s.push_str("\n\n");
236            s.push_str(trimmed);
237            s
238        };
239
240        self.input_manager.set_content(merged);
241        self.input_manager.set_cursor(self.input_manager.content().len());
242        self.suggested_prompt_state.active = true;
243        self.input_compact_mode = self.input_compact_placeholder().is_some();
244        self.mark_dirty();
245    }
246
247    /// Calculate remaining newline capacity in the input field
248    fn remaining_newline_capacity(&self) -> usize {
249        let content = self.input_manager.content();
250        let mut newline_count = content.matches('\n').count();
251        if let Some(range) = self.input_manager.compact_paste_range()
252            && range.end <= content.len()
253            && content.is_char_boundary(range.start)
254            && content.is_char_boundary(range.end)
255        {
256            newline_count = newline_count.saturating_sub(content[range].matches('\n').count());
257        }
258
259        ui::INLINE_INPUT_MAX_LINES.saturating_sub(1).saturating_sub(newline_count)
260    }
261
262    /// Check if a newline can be inserted
263    fn can_insert_newline(&self) -> bool {
264        self.remaining_newline_capacity() > 0
265    }
266
267    /// Delete the character before the cursor (backspace)
268    pub(crate) fn delete_char(&mut self) {
269        // One Backspace right after a collapsed multi-line paste removes the
270        // whole inserted block instead of peeling single characters. This only
271        // applies while collapsed: in the expanded full-text view Backspace
272        // peels one character like normal multiline editing. When the
273        // user has an active selection, the selection must win (it removes
274        // exactly what the user highlighted).
275        if self.input_compact_mode
276            && let Some(range) = self.input_manager.compact_paste_range()
277            && range.end > range.start
278            && self.input_manager.selection_range().is_none()
279            && self.input_manager.cursor() == range.end
280        {
281            let content = self.input_manager.content();
282            if content.is_char_boundary(range.start) && content.is_char_boundary(range.end) {
283                self.input_manager.replace_range(range.start, range.end, "");
284                self.refresh_input_edit_state();
285                return;
286            }
287        }
288        self.input_manager.backspace();
289        self.refresh_input_edit_state();
290    }
291
292    /// Delete the character at the cursor (forward delete)
293    pub(crate) fn delete_char_forward(&mut self) {
294        self.input_manager.delete();
295        self.refresh_input_edit_state();
296    }
297
298    /// Delete the word before the cursor
299    pub(crate) fn delete_word_backward(&mut self) {
300        if self.input_manager.delete_selection() {
301            self.refresh_input_edit_state();
302            return;
303        }
304        let cursor = self.input_manager.cursor();
305        if cursor == 0 {
306            return;
307        }
308
309        let delete_start = previous_word_boundary(self.input_manager.content(), cursor);
310
311        if delete_start < cursor {
312            self.input_manager.replace_range(delete_start, cursor, "");
313            self.input_manager.set_cursor(delete_start);
314            self.refresh_input_edit_state();
315        }
316    }
317
318    pub(crate) fn delete_word_forward(&mut self) {
319        self.input_manager.delete_word_forward();
320        self.refresh_input_edit_state();
321    }
322
323    /// Delete whitespace around the cursor (Readline Alt+\)
324    pub(crate) fn delete_whitespace_around_cursor(&mut self) {
325        self.input_manager.delete_whitespace_around_cursor();
326        self.refresh_input_edit_state();
327    }
328
329    /// Transpose characters at cursor position (Readline Ctrl+T)
330    pub(crate) fn transpose_chars(&mut self) {
331        self.input_manager.transpose_chars();
332        self.refresh_input_edit_state();
333    }
334
335    /// Transpose words at cursor position (Readline Alt+T)
336    pub(crate) fn transpose_words(&mut self) {
337        self.input_manager.transpose_words();
338        self.refresh_input_edit_state();
339    }
340
341    /// Uppercase the current word (Readline Alt+U)
342    pub(crate) fn uppercase_word(&mut self) {
343        self.input_manager.uppercase_word();
344        self.refresh_input_edit_state();
345    }
346
347    /// Lowercase the current word (Readline Alt+L)
348    pub(crate) fn lowercase_word(&mut self) {
349        self.input_manager.lowercase_word();
350        self.refresh_input_edit_state();
351    }
352
353    /// Capitalize the current word (Readline Alt+C)
354    pub(crate) fn capitalize_word(&mut self) {
355        self.input_manager.capitalize_word();
356        self.refresh_input_edit_state();
357    }
358
359    /// Delete from cursor to end of current line (Command+Delete on macOS)
360    pub(crate) fn delete_to_end_of_line(&mut self) {
361        if self.input_manager.delete_selection() {
362            self.refresh_input_edit_state();
363            return;
364        }
365        let content = self.input_manager.content();
366        let cursor = self.input_manager.cursor();
367
368        let rest = &content[cursor..];
369        let delete_len = if let Some(newline_pos) = rest.find('\n') {
370            newline_pos
371        } else {
372            rest.len()
373        };
374
375        if delete_len > 0 {
376            self.input_manager.replace_range(cursor, cursor + delete_len, "");
377            self.refresh_input_edit_state();
378        }
379    }
380
381    /// Move cursor left by one character
382    pub(crate) fn move_left(&mut self) {
383        self.input_manager.move_cursor_left();
384    }
385
386    /// Move cursor right by one character
387    pub(crate) fn move_right(&mut self) {
388        self.input_manager.move_cursor_right();
389    }
390
391    pub(crate) fn select_left(&mut self) {
392        let cursor = self.input_manager.cursor();
393        let content = self.input_manager.content();
394        let pos = prev_char_boundary(content, cursor);
395        self.input_manager.set_cursor_with_selection(pos);
396    }
397
398    pub(crate) fn select_right(&mut self) {
399        let cursor = self.input_manager.cursor();
400        let content = self.input_manager.content();
401        let pos = next_char_boundary(content, cursor);
402        self.input_manager.set_cursor_with_selection(pos);
403    }
404
405    /// Move cursor left to the start of the previous word
406    pub(crate) fn move_left_word(&mut self) {
407        let cursor = previous_word_boundary(self.input_manager.content(), self.input_manager.cursor());
408        self.input_manager.set_cursor(cursor);
409    }
410    /// Move cursor right to the start of the next word
411    pub(crate) fn move_right_word(&mut self) {
412        let cursor = next_word_boundary(self.input_manager.content(), self.input_manager.cursor());
413        self.input_manager.set_cursor(cursor);
414    }
415    /// Move cursor to the start of the line
416    pub(crate) fn move_to_start(&mut self) {
417        self.input_manager.move_cursor_to_start();
418    }
419
420    /// Move cursor to the end of the line
421    pub(crate) fn move_to_end(&mut self) {
422        self.input_manager.move_cursor_to_end();
423    }
424
425    /// Move cursor to the beginning of the current logical line.
426    ///
427    /// Multi-line input moves within the cursor's line; single-line input
428    /// degenerates to buffer start via [`InputManager::move_cursor_to_start_of_line`].
429    pub(crate) fn move_to_start_of_line(&mut self) {
430        self.input_manager.move_cursor_to_start_of_line();
431    }
432
433    /// Move cursor to the end of the current logical line.
434    ///
435    /// Multi-line input moves within the cursor's line; single-line input
436    /// degenerates to buffer end via [`InputManager::move_cursor_to_end_of_line`].
437    pub(crate) fn move_to_end_of_line(&mut self) {
438        self.input_manager.move_cursor_to_end_of_line();
439    }
440
441    pub(crate) fn select_to_start(&mut self) {
442        self.input_manager.set_cursor_with_selection(0);
443    }
444
445    pub(crate) fn select_to_end(&mut self) {
446        self.input_manager.set_cursor_with_selection(self.input_manager.content().len());
447    }
448
449    /// Extend selection to the beginning of the current logical line.
450    pub(crate) fn select_to_start_of_line(&mut self) {
451        let (line_start, _) = self.input_manager.current_line_byte_range();
452        self.input_manager.set_cursor_with_selection(line_start);
453    }
454
455    /// Extend selection to the end of the current logical line.
456    pub(crate) fn select_to_end_of_line(&mut self) {
457        let (_, line_end) = self.input_manager.current_line_byte_range();
458        self.input_manager.set_cursor_with_selection(line_end);
459    }
460
461    /// Clear the current logical line, or the entire input when single-line.
462    ///
463    /// Dimension key: `line_start..line_end` are byte offsets of the cursor's
464    /// logical line; `clear_start..clear_end` expands that range to cover an
465    /// overlapping compact paste block (`[Pasted Content N chars]`) so a
466    /// collapsed multi-line paste is removed atomically. The expansion only
467    /// applies while collapsed; in the expanded full-text view line clears
468    /// behave like normal multiline editing. Image placeholders
469    /// (`[Image #N]`) live inside their line, so line deletion already covers
470    /// them; single-line clears go through [`InputManager::clear`] which also
471    /// drops attachments and compact state.
472    pub(crate) fn clear_current_line_or_all(&mut self) {
473        if self.input_manager.delete_selection() {
474            self.refresh_input_edit_state();
475            return;
476        }
477        let content_len = self.input_manager.content().len();
478        if content_len == 0 {
479            return;
480        }
481        if self.input_manager.is_single_line() {
482            self.input_manager.clear();
483            self.refresh_input_edit_state();
484            return;
485        }
486
487        let cursor = self.input_manager.cursor().min(content_len);
488        let (line_start, line_end) = self.input_manager.current_line_byte_range();
489        let mut clear_start = line_start.min(content_len);
490        let mut clear_end = line_end.min(content_len);
491
492        if self.input_compact_mode
493            && let Some(range) = self.input_manager.compact_paste_range()
494            && range.start < range.end
495        {
496            // Clamp stale ranges to current content before expanding.
497            let range_start = range.start.min(content_len);
498            let range_end = range.end.min(content_len);
499            if range_start < range_end {
500                let overlaps_line = range_start < clear_end && clear_start < range_end;
501                let cursor_inside = cursor >= range_start && cursor <= range_end;
502                if overlaps_line || cursor_inside {
503                    clear_start = clear_start.min(range_start);
504                    clear_end = clear_end.max(range_end);
505                }
506            }
507        }
508
509        // Clamp to char boundaries so multi-byte content never panics.
510        let content = self.input_manager.content();
511        while clear_start > 0 && !content.is_char_boundary(clear_start) {
512            clear_start -= 1;
513        }
514        while clear_end < content.len() && !content.is_char_boundary(clear_end) {
515            clear_end += 1;
516        }
517        if clear_start >= clear_end {
518            return;
519        }
520        self.input_manager.replace_range(clear_start, clear_end, "");
521        self.input_manager.set_cursor(clear_start);
522        self.refresh_input_edit_state();
523    }
524
525    /// Remember submitted input in history
526    pub(crate) fn remember_submitted_input(&mut self, submitted: super::input_manager::InputHistoryEntry) {
527        self.input_manager.add_to_history(submitted);
528    }
529
530    /// Navigate to previous history entry (disabled to prevent cursor flickering)
531    pub(crate) fn navigate_history_previous(&mut self) -> bool {
532        if let Some(previous) = self.input_manager.go_to_previous_history() {
533            self.input_manager.apply_history_entry(previous);
534            self.input_compact_mode = self.input_compact_placeholder().is_some();
535            true
536        } else {
537            false
538        }
539    }
540
541    /// Navigate to next history entry (disabled to prevent cursor flickering)
542    pub(crate) fn navigate_history_next(&mut self) -> bool {
543        if let Some(next) = self.input_manager.go_to_next_history() {
544            self.input_manager.apply_history_entry(next);
545            self.input_compact_mode = self.input_compact_placeholder().is_some();
546            true
547        } else {
548            false
549        }
550    }
551
552    /// Arrow-Up handling for single-row history gating.
553    ///
554    /// Returns `true` when the key was consumed by an intra-composer cursor
555    /// move (caller should `mark_dirty()` and emit no history event).
556    /// Movement is by logical lines; wrapped visual rows are handled by
557    /// [`Session::move_up_within_composer`]. The events layer consumes the
558    /// key for all multi-row input, so a `false` return only reaches history
559    /// traversal for single-row input.
560    pub(crate) fn move_cursor_up_for_history(&mut self) -> bool {
561        if !self.input_enabled {
562            return false;
563        }
564        self.clear_inline_prompt_suggestion();
565        self.input_manager.move_cursor_up()
566    }
567
568    /// Arrow-Down handling for single-row history gating.
569    ///
570    /// Returns `true` when the key was consumed by an intra-composer cursor
571    /// move (caller should `mark_dirty()` and emit no history event).
572    /// Movement is by logical lines; wrapped visual rows are handled by
573    /// [`Session::move_down_within_composer`]. The events layer consumes the
574    /// key for all multi-row input, so a `false` return only reaches history
575    /// traversal for single-row input.
576    pub(crate) fn move_cursor_down_for_history(&mut self) -> bool {
577        if !self.input_enabled {
578            return false;
579        }
580        self.clear_inline_prompt_suggestion();
581        self.input_manager.move_cursor_down()
582    }
583
584    /// Arrow-Up movement within a multi-row composer, honoring soft wraps.
585    ///
586    /// Uses visual-row movement when the input area is known, logical-line
587    /// movement before the first render. Returns `true` when the cursor
588    /// moved; the events layer consumes the key for multi-row input even
589    /// when already at the edge (returns `false` there).
590    pub(crate) fn move_up_within_composer(&mut self) -> bool {
591        if !self.input_enabled {
592            return false;
593        }
594        self.clear_inline_prompt_suggestion();
595        if self.input_visual_geometry().is_some() {
596            self.move_cursor_up_within_visual()
597        } else {
598            self.input_manager.move_cursor_up()
599        }
600    }
601
602    /// Arrow-Down movement within a multi-row composer, honoring soft wraps.
603    ///
604    /// Uses visual-row movement when the input area is known, logical-line
605    /// movement before the first render. Returns `true` when the cursor
606    /// moved; the events layer consumes the key for multi-row input even
607    /// when already at the edge (returns `false` there).
608    pub(crate) fn move_down_within_composer(&mut self) -> bool {
609        if !self.input_enabled {
610            return false;
611        }
612        self.clear_inline_prompt_suggestion();
613        if self.input_visual_geometry().is_some() {
614            self.move_cursor_down_within_visual()
615        } else {
616            self.input_manager.move_cursor_down()
617        }
618    }
619
620    /// Returns the current history position for status bar display
621    /// Returns (current_index, total_entries) or None if not navigating history
622    pub fn history_position(&self) -> Option<(usize, usize)> {
623        self.input_manager.history_index().map(|idx| {
624            let total = self.input_manager.history().len();
625            (total - idx, total)
626        })
627    }
628}