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