Skip to main content

vtcode_tui/core_tui/session/
input.rs

1use super::{PLACEHOLDER_COLOR, Session, measure_text_width, ratatui_style_from_inline};
2use crate::config::constants::ui;
3use crate::ui::tui::types::InlineTextStyle;
4use crate::utils::file_utils::is_image_path;
5use anstyle::{Color as AnsiColorEnum, Effects};
6use ratatui::{
7    buffer::Buffer,
8    prelude::*,
9    widgets::{Block, Clear, Padding, Paragraph, Wrap},
10};
11use regex::Regex;
12use std::path::Path;
13use std::sync::LazyLock;
14use tui_shimmer::shimmer_spans_with_style_at_phase;
15use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
16
17struct InputRender {
18    text: Text<'static>,
19    cursor_x: u16,
20    cursor_y: u16,
21}
22
23#[derive(Default)]
24struct InputLineBuffer {
25    prefix: String,
26    text: String,
27    prefix_width: u16,
28    text_width: u16,
29}
30
31impl InputLineBuffer {
32    fn new(prefix: String, prefix_width: u16) -> Self {
33        Self {
34            prefix,
35            text: String::new(),
36            prefix_width,
37            text_width: 0,
38        }
39    }
40}
41
42struct InputLayout {
43    buffers: Vec<InputLineBuffer>,
44    cursor_line_idx: usize,
45    cursor_column: u16,
46}
47
48impl Session {
49    pub(super) fn render_input(&mut self, frame: &mut Frame<'_>, area: Rect) {
50        frame.render_widget(Clear, area);
51        if area.height == 0 {
52            self.set_input_area(None);
53            return;
54        }
55
56        self.set_input_area(Some(area));
57
58        let mut input_area = area;
59        let mut status_area = None;
60        let mut status_line = None;
61
62        // Always split the status row from the input area when there is enough
63        // height.  This prevents the input block from visually jumping when the
64        // status text appears/disappears during agent execution (the layout in
65        // impl_render.rs always reserves 1 row for the status line).
66        if area.height >= 2 {
67            let block_height = area.height.saturating_sub(1).max(1);
68            input_area.height = block_height;
69            let status_rect = Rect::new(area.x, area.y + block_height, area.width, 1);
70            status_area = Some(status_rect);
71            status_line = self.render_input_status_line(area.width);
72        }
73
74        let background_style = self.styles.input_background_style();
75        let block = Block::new().style(background_style).padding(Padding::new(
76            ui::INLINE_INPUT_PADDING_HORIZONTAL,
77            ui::INLINE_INPUT_PADDING_HORIZONTAL,
78            ui::INLINE_INPUT_PADDING_VERTICAL,
79            ui::INLINE_INPUT_PADDING_VERTICAL,
80        ));
81        let inner = block.inner(input_area);
82        let input_render = self.build_input_render(inner.width, inner.height);
83        let paragraph = Paragraph::new(input_render.text)
84            .style(background_style)
85            .wrap(Wrap { trim: false });
86        frame.render_widget(paragraph.block(block), input_area);
87
88        if self.cursor_should_be_visible() && inner.width > 0 && inner.height > 0 {
89            let cursor_x = input_render
90                .cursor_x
91                .min(inner.width.saturating_sub(1))
92                .saturating_add(inner.x);
93            let cursor_y = input_render
94                .cursor_y
95                .min(inner.height.saturating_sub(1))
96                .saturating_add(inner.y);
97            if self.use_fake_cursor() {
98                render_fake_cursor(frame.buffer_mut(), cursor_x, cursor_y);
99            } else {
100                frame.set_cursor_position(Position::new(cursor_x, cursor_y));
101            }
102        }
103
104        if let Some(status_rect) = status_area {
105            if let Some(line) = status_line {
106                let paragraph = Paragraph::new(line)
107                    .style(self.styles.default_style())
108                    .wrap(Wrap { trim: false });
109                frame.render_widget(paragraph, status_rect);
110            } else {
111                // Clear the reserved status row to prevent stale artifacts
112                frame.render_widget(Clear, status_rect);
113            }
114        }
115    }
116
117    pub(crate) fn desired_input_lines(&self, inner_width: u16) -> u16 {
118        if inner_width == 0 {
119            return 1;
120        }
121
122        if self.input_compact_mode
123            && self.input_manager.cursor() == self.input_manager.content().len()
124            && self.input_compact_placeholder().is_some()
125        {
126            return 1;
127        }
128
129        if self.input_manager.content().is_empty() {
130            return 1;
131        }
132
133        let prompt_width = UnicodeWidthStr::width(self.prompt_prefix.as_str()) as u16;
134        let prompt_display_width = prompt_width.min(inner_width);
135        let layout = self.input_layout(inner_width, prompt_display_width);
136        let line_count = layout.buffers.len().max(1);
137        let capped = line_count.min(ui::INLINE_INPUT_MAX_LINES.max(1));
138        capped as u16
139    }
140
141    pub(crate) fn apply_input_height(&mut self, height: u16) {
142        let resolved = height.max(Self::input_block_height_for_lines(1));
143        if self.input_height != resolved {
144            self.input_height = resolved;
145            crate::ui::tui::session::render::recalculate_transcript_rows(self);
146        }
147    }
148
149    pub(crate) fn input_block_height_for_lines(lines: u16) -> u16 {
150        lines
151            .max(1)
152            .saturating_add(ui::INLINE_INPUT_PADDING_VERTICAL.saturating_mul(2))
153    }
154
155    fn input_layout(&self, width: u16, prompt_display_width: u16) -> InputLayout {
156        let indent_prefix = " ".repeat(prompt_display_width as usize);
157        let mut buffers = vec![InputLineBuffer::new(
158            self.prompt_prefix.clone(),
159            prompt_display_width,
160        )];
161        let secure_prompt_active = self.secure_prompt_active();
162        let mut cursor_line_idx = 0usize;
163        let mut cursor_column = prompt_display_width;
164        let input_content = self.input_manager.content();
165        let cursor_pos = self.input_manager.cursor();
166        let mut cursor_set = cursor_pos == 0;
167
168        for (idx, ch) in input_content.char_indices() {
169            if !cursor_set
170                && cursor_pos == idx
171                && let Some(current) = buffers.last()
172            {
173                cursor_line_idx = buffers.len() - 1;
174                cursor_column = current.prefix_width + current.text_width;
175                cursor_set = true;
176            }
177
178            if ch == '\n' {
179                let end = idx + ch.len_utf8();
180                buffers.push(InputLineBuffer::new(
181                    indent_prefix.clone(),
182                    prompt_display_width,
183                ));
184                if !cursor_set && cursor_pos == end {
185                    cursor_line_idx = buffers.len() - 1;
186                    cursor_column = prompt_display_width;
187                    cursor_set = true;
188                }
189                continue;
190            }
191
192            let display_ch = if secure_prompt_active { '•' } else { ch };
193            let char_width = UnicodeWidthChar::width(display_ch).unwrap_or(0) as u16;
194
195            if let Some(current) = buffers.last_mut() {
196                let capacity = width.saturating_sub(current.prefix_width);
197                if capacity > 0
198                    && current.text_width + char_width > capacity
199                    && !current.text.is_empty()
200                {
201                    buffers.push(InputLineBuffer::new(
202                        indent_prefix.clone(),
203                        prompt_display_width,
204                    ));
205                }
206            }
207
208            if let Some(current) = buffers.last_mut() {
209                current.text.push(display_ch);
210                current.text_width = current.text_width.saturating_add(char_width);
211            }
212
213            let end = idx + ch.len_utf8();
214            if !cursor_set
215                && cursor_pos == end
216                && let Some(current) = buffers.last()
217            {
218                cursor_line_idx = buffers.len() - 1;
219                cursor_column = current.prefix_width + current.text_width;
220                cursor_set = true;
221            }
222        }
223
224        if !cursor_set && let Some(current) = buffers.last() {
225            cursor_line_idx = buffers.len() - 1;
226            cursor_column = current.prefix_width + current.text_width;
227        }
228
229        InputLayout {
230            buffers,
231            cursor_line_idx,
232            cursor_column,
233        }
234    }
235
236    fn build_input_render(&self, width: u16, height: u16) -> InputRender {
237        if width == 0 || height == 0 {
238            return InputRender {
239                text: Text::default(),
240                cursor_x: 0,
241                cursor_y: 0,
242            };
243        }
244
245        let max_visible_lines = height.max(1).min(ui::INLINE_INPUT_MAX_LINES as u16) as usize;
246
247        let mut prompt_style = self.prompt_style.clone();
248        if prompt_style.color.is_none() {
249            prompt_style.color = self.theme.primary.or(self.theme.foreground);
250        }
251        let prompt_style = ratatui_style_from_inline(&prompt_style, self.theme.foreground);
252        let prompt_width = UnicodeWidthStr::width(self.prompt_prefix.as_str()) as u16;
253        let prompt_display_width = prompt_width.min(width);
254
255        let cursor_at_end = self.input_manager.cursor() == self.input_manager.content().len();
256        if self.input_compact_mode
257            && cursor_at_end
258            && let Some(placeholder) = self.input_compact_placeholder()
259        {
260            let placeholder_style = InlineTextStyle {
261                color: Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
262                bg_color: None,
263                effects: Effects::DIMMED,
264            };
265            let style = ratatui_style_from_inline(
266                &placeholder_style,
267                Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
268            );
269            let placeholder_width = UnicodeWidthStr::width(placeholder.as_str()) as u16;
270            return InputRender {
271                text: Text::from(vec![Line::from(vec![
272                    Span::styled(self.prompt_prefix.clone(), prompt_style),
273                    Span::styled(placeholder, style),
274                ])]),
275                cursor_x: prompt_display_width.saturating_add(placeholder_width),
276                cursor_y: 0,
277            };
278        }
279
280        if self.input_manager.content().is_empty() {
281            let mut spans = Vec::new();
282            spans.push(Span::styled(self.prompt_prefix.clone(), prompt_style));
283
284            if let Some(placeholder) = &self.placeholder {
285                let placeholder_style = self.placeholder_style.clone().unwrap_or(InlineTextStyle {
286                    color: Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
287                    bg_color: None,
288                    effects: Effects::ITALIC,
289                });
290                let style = ratatui_style_from_inline(
291                    &placeholder_style,
292                    Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
293                );
294                spans.push(Span::styled(placeholder.clone(), style));
295            }
296
297            return InputRender {
298                text: Text::from(vec![Line::from(spans)]),
299                cursor_x: prompt_display_width,
300                cursor_y: 0,
301            };
302        }
303
304        let accent_style =
305            ratatui_style_from_inline(&self.styles.accent_inline_style(), self.theme.foreground);
306        let layout = self.input_layout(width, prompt_display_width);
307        let total_lines = layout.buffers.len();
308        let visible_limit = max_visible_lines.max(1);
309        let mut start = total_lines.saturating_sub(visible_limit);
310        if layout.cursor_line_idx < start {
311            start = layout.cursor_line_idx.saturating_sub(visible_limit - 1);
312        }
313        let end = (start + visible_limit).min(total_lines);
314        let cursor_y = layout.cursor_line_idx.saturating_sub(start) as u16;
315
316        let mut lines = Vec::new();
317        for buffer in &layout.buffers[start..end] {
318            let mut spans = Vec::new();
319            spans.push(Span::styled(buffer.prefix.clone(), prompt_style));
320            if !buffer.text.is_empty() {
321                spans.push(Span::styled(buffer.text.clone(), accent_style));
322            }
323            lines.push(Line::from(spans));
324        }
325
326        if lines.is_empty() {
327            lines.push(Line::from(vec![Span::styled(
328                self.prompt_prefix.clone(),
329                prompt_style,
330            )]));
331        }
332
333        InputRender {
334            text: Text::from(lines),
335            cursor_x: layout.cursor_column,
336            cursor_y,
337        }
338    }
339
340    pub(super) fn input_compact_placeholder(&self) -> Option<String> {
341        let content = self.input_manager.content();
342        let trimmed = content.trim();
343        let attachment_count = self.input_manager.attachments().len();
344        if trimmed.is_empty() && attachment_count == 0 {
345            return None;
346        }
347
348        if let Some(label) = compact_image_label(trimmed) {
349            return Some(format!("[Image: {label}]"));
350        }
351
352        if attachment_count > 0 {
353            let label = if attachment_count == 1 {
354                "1 attachment".to_string()
355            } else {
356                format!("{attachment_count} attachments")
357            };
358            if trimmed.is_empty() {
359                return Some(format!("[Image: {label}]"));
360            }
361            if let Some(compact) = compact_image_placeholders(content) {
362                return Some(format!("[Image: {label}] {compact}"));
363            }
364            return Some(format!("[Image: {label}] {trimmed}"));
365        }
366
367        let line_count = content.split('\n').count();
368        if line_count >= ui::INLINE_PASTE_COLLAPSE_LINE_THRESHOLD {
369            let char_count = content.chars().count();
370            return Some(format!("[Pasted Content {char_count} chars]"));
371        }
372
373        if let Some(compact) = compact_image_placeholders(content) {
374            return Some(compact);
375        }
376
377        None
378    }
379
380    fn render_input_status_line(&self, width: u16) -> Option<Line<'static>> {
381        if width == 0 {
382            return None;
383        }
384
385        let left = self
386            .input_status_left
387            .as_ref()
388            .map(|value| value.trim().to_owned())
389            .filter(|value| !value.is_empty());
390        let right = self
391            .input_status_right
392            .as_ref()
393            .map(|value| value.trim().to_owned())
394            .filter(|value| !value.is_empty());
395
396        // Build scroll indicator if enabled
397        let scroll_indicator = if ui::SCROLL_INDICATOR_ENABLED {
398            Some(self.build_scroll_indicator())
399        } else {
400            None
401        };
402
403        if left.is_none() && right.is_none() && scroll_indicator.is_none() {
404            return None;
405        }
406
407        let dim_style = self.styles.default_style().add_modifier(Modifier::DIM);
408        let mut spans = Vec::new();
409
410        // Add left content (git status or shimmered activity)
411        if let Some(left_value) = left.as_ref() {
412            if status_requires_shimmer(left_value)
413                && self.appearance.should_animate_progress_status()
414            {
415                spans.extend(shimmer_spans_with_style_at_phase(
416                    left_value,
417                    dim_style,
418                    self.shimmer_state.phase(),
419                ));
420            } else {
421                spans.extend(self.create_git_status_spans(left_value, dim_style));
422            }
423        }
424
425        // Build right side spans (scroll indicator + optional right content)
426        let mut right_spans: Vec<Span<'static>> = Vec::new();
427        if let Some(scroll) = &scroll_indicator {
428            right_spans.push(Span::styled(scroll.clone(), dim_style));
429        }
430        if let Some(right_value) = &right {
431            if !right_spans.is_empty() {
432                right_spans.push(Span::raw(" "));
433            }
434            right_spans.push(Span::styled(right_value.clone(), dim_style));
435        }
436
437        if !right_spans.is_empty() {
438            let left_width: u16 = spans.iter().map(|s| measure_text_width(&s.content)).sum();
439            let right_width: u16 = right_spans
440                .iter()
441                .map(|s| measure_text_width(&s.content))
442                .sum();
443            let padding = width.saturating_sub(left_width + right_width);
444
445            if padding > 0 {
446                spans.push(Span::raw(" ".repeat(padding as usize)));
447            } else if !spans.is_empty() {
448                spans.push(Span::raw(" "));
449            }
450            spans.extend(right_spans);
451        }
452
453        if spans.is_empty() {
454            return None;
455        }
456
457        Some(Line::from(spans))
458    }
459
460    /// Build scroll indicator string with percentage
461    fn build_scroll_indicator(&self) -> String {
462        let percent = self.scroll_manager.progress_percent();
463        format!("{} {:>3}%", ui::SCROLL_INDICATOR_FORMAT, percent)
464    }
465
466    #[allow(dead_code)]
467    fn create_git_status_spans(&self, text: &str, default_style: Style) -> Vec<Span<'static>> {
468        if let Some((branch_part, indicator_part)) = text.rsplit_once(" | ") {
469            let mut spans = Vec::new();
470            let branch_trim = branch_part.trim_end();
471            if !branch_trim.is_empty() {
472                spans.push(Span::styled(branch_trim.to_owned(), default_style));
473            }
474            spans.push(Span::raw(" "));
475
476            let indicator_trim = indicator_part.trim();
477            let indicator_style = if indicator_trim == ui::HEADER_GIT_DIRTY_SUFFIX {
478                Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
479            } else if indicator_trim == ui::HEADER_GIT_CLEAN_SUFFIX {
480                Style::default()
481                    .fg(Color::Green)
482                    .add_modifier(Modifier::BOLD)
483            } else {
484                self.styles.accent_style().add_modifier(Modifier::BOLD)
485            };
486
487            spans.push(Span::styled(indicator_trim.to_owned(), indicator_style));
488            spans
489        } else {
490            vec![Span::styled(text.to_owned(), default_style)]
491        }
492    }
493
494    fn cursor_should_be_visible(&self) -> bool {
495        let loading_state = self.is_running_activity() || self.has_status_spinner();
496        self.cursor_visible && (self.input_enabled || loading_state)
497    }
498
499    fn use_fake_cursor(&self) -> bool {
500        self.has_status_spinner()
501    }
502
503    fn secure_prompt_active(&self) -> bool {
504        self.modal
505            .as_ref()
506            .and_then(|modal| modal.secure_prompt.as_ref())
507            .is_some()
508    }
509
510    /// Build input render data for external widgets
511    pub fn build_input_widget_data(&self, width: u16, height: u16) -> InputWidgetData {
512        let input_render = self.build_input_render(width, height);
513        let background_style = self.styles.input_background_style();
514
515        InputWidgetData {
516            text: input_render.text,
517            cursor_x: input_render.cursor_x,
518            cursor_y: input_render.cursor_y,
519            cursor_should_be_visible: self.cursor_should_be_visible(),
520            use_fake_cursor: self.use_fake_cursor(),
521            background_style,
522            default_style: self.styles.default_style(),
523        }
524    }
525
526    /// Build input status line for external widgets
527    pub fn build_input_status_widget_data(&self, width: u16) -> Option<Vec<Span<'static>>> {
528        self.render_input_status_line(width).map(|line| line.spans)
529    }
530}
531
532fn compact_image_label(content: &str) -> Option<String> {
533    let trimmed = content.trim();
534    if trimmed.is_empty() {
535        return None;
536    }
537
538    let unquoted = trimmed
539        .strip_prefix('"')
540        .and_then(|value| value.strip_suffix('"'))
541        .or_else(|| {
542            trimmed
543                .strip_prefix('\'')
544                .and_then(|value| value.strip_suffix('\''))
545        })
546        .unwrap_or(trimmed);
547
548    if unquoted.starts_with("data:image/") {
549        return Some("inline image".to_string());
550    }
551
552    let windows_drive = unquoted.as_bytes().get(1).is_some_and(|ch| *ch == b':')
553        && unquoted
554            .as_bytes()
555            .get(2)
556            .is_some_and(|ch| *ch == b'\\' || *ch == b'/');
557    let starts_like_path = unquoted.starts_with('@')
558        || unquoted.starts_with("file://")
559        || unquoted.starts_with('/')
560        || unquoted.starts_with("./")
561        || unquoted.starts_with("../")
562        || unquoted.starts_with("~/")
563        || windows_drive;
564    if !starts_like_path {
565        return None;
566    }
567
568    let without_at = unquoted.strip_prefix('@').unwrap_or(unquoted);
569
570    // Skip npm scoped package patterns like @scope/package@version
571    if without_at.contains('/')
572        && !without_at.starts_with('.')
573        && !without_at.starts_with('/')
574        && !without_at.starts_with("~/")
575    {
576        // Check if this looks like @scope/package (npm package)
577        let parts: Vec<&str> = without_at.split('/').collect();
578        if parts.len() >= 2 && !parts[0].is_empty() {
579            // Reject if it looks like a package name (no extension on second component)
580            if !parts[parts.len() - 1].contains('.') {
581                return None;
582            }
583        }
584    }
585
586    let without_scheme = without_at.strip_prefix("file://").unwrap_or(without_at);
587    let path = Path::new(without_scheme);
588    if !is_image_path(path) {
589        return None;
590    }
591
592    let label = path
593        .file_name()
594        .and_then(|name| name.to_str())
595        .unwrap_or(without_scheme);
596    Some(label.to_string())
597}
598
599static IMAGE_PATH_INLINE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
600    Regex::new(
601        r#"(?ix)
602        (?:^|[\s\(\[\{<\"'`])
603        (
604            @?
605            (?:file://)?
606            (?:
607                ~/(?:[^\n/]+/)+
608              | /(?:[^\n/]+/)+
609              | [A-Za-z]:[\\/](?:[^\n\\\/]+[\\/])+
610            )
611            [^\n]*?
612            \.(?:png|jpe?g|gif|bmp|webp|tiff?|svg)
613        )"#,
614    )
615    .expect("Failed to compile inline image path regex")
616});
617
618fn compact_image_placeholders(content: &str) -> Option<String> {
619    let mut matches = Vec::new();
620    for capture in IMAGE_PATH_INLINE_REGEX.captures_iter(content) {
621        let Some(path_match) = capture.get(1) else {
622            continue;
623        };
624        let raw = path_match.as_str();
625        let Some(label) = image_label_for_path(raw) else {
626            continue;
627        };
628        matches.push((path_match.start(), path_match.end(), label));
629    }
630
631    if matches.is_empty() {
632        return None;
633    }
634
635    let mut result = String::with_capacity(content.len());
636    let mut last_end = 0usize;
637    for (start, end, label) in matches {
638        if start < last_end {
639            continue;
640        }
641        result.push_str(&content[last_end..start]);
642        result.push_str(&format!("[Image: {label}]"));
643        last_end = end;
644    }
645    if last_end < content.len() {
646        result.push_str(&content[last_end..]);
647    }
648
649    Some(result)
650}
651
652fn image_label_for_path(raw: &str) -> Option<String> {
653    let trimmed = raw.trim_matches(|ch: char| matches!(ch, '"' | '\'')).trim();
654    if trimmed.is_empty() {
655        return None;
656    }
657
658    let without_at = trimmed.strip_prefix('@').unwrap_or(trimmed);
659    let without_scheme = without_at.strip_prefix("file://").unwrap_or(without_at);
660    let unescaped = unescape_whitespace(without_scheme);
661    let path = Path::new(unescaped.as_str());
662    if !is_image_path(path) {
663        return None;
664    }
665
666    let label = path
667        .file_name()
668        .and_then(|name| name.to_str())
669        .unwrap_or(unescaped.as_str());
670    Some(label.to_string())
671}
672
673fn unescape_whitespace(token: &str) -> String {
674    let mut result = String::with_capacity(token.len());
675    let mut chars = token.chars().peekable();
676    while let Some(ch) = chars.next() {
677        if ch == '\\'
678            && let Some(next) = chars.peek()
679            && next.is_ascii_whitespace()
680        {
681            result.push(*next);
682            chars.next();
683            continue;
684        }
685        result.push(ch);
686    }
687    result
688}
689
690fn is_spinner_frame(indicator: &str) -> bool {
691    matches!(
692        indicator,
693        "⠋" | "⠙"
694            | "⠹"
695            | "⠸"
696            | "⠼"
697            | "⠴"
698            | "⠦"
699            | "⠧"
700            | "⠇"
701            | "⠏"
702            | "-"
703            | "\\"
704            | "|"
705            | "/"
706            | "."
707    )
708}
709
710pub(crate) fn status_requires_shimmer(text: &str) -> bool {
711    if text.contains("Running command:")
712        || text.contains("Running tool:")
713        || text.contains("Running:")
714        || text.contains("Running ")
715        || text.contains("Executing ")
716        || text.contains("Press Ctrl+C to cancel")
717    {
718        return true;
719    }
720    let Some((indicator, rest)) = text.split_once(' ') else {
721        return false;
722    };
723    if indicator.chars().count() != 1 || rest.trim().is_empty() {
724        return false;
725    }
726    is_spinner_frame(indicator)
727}
728
729/// Data structure for input widget rendering
730#[derive(Clone, Debug)]
731pub struct InputWidgetData {
732    pub text: Text<'static>,
733    pub cursor_x: u16,
734    pub cursor_y: u16,
735    pub cursor_should_be_visible: bool,
736    pub use_fake_cursor: bool,
737    pub background_style: Style,
738    pub default_style: Style,
739}
740
741fn render_fake_cursor(buf: &mut Buffer, cursor_x: u16, cursor_y: u16) {
742    if let Some(cell) = buf.cell_mut((cursor_x, cursor_y)) {
743        let mut style = cell.style();
744        style = style.add_modifier(Modifier::REVERSED);
745        cell.set_style(style);
746        if cell.symbol().is_empty() {
747            cell.set_symbol(" ");
748        }
749    }
750}