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