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    /// Character index in the original input where this buffer's text starts.
32    char_start: usize,
33}
34
35impl InputLineBuffer {
36    fn new(prefix: String, prefix_width: u16, char_start: usize) -> Self {
37        Self {
38            prefix,
39            text: String::new(),
40            prefix_width,
41            text_width: 0,
42            char_start,
43        }
44    }
45}
46
47/// Token type for syntax highlighting in the input field.
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49enum InputTokenKind {
50    Normal,
51    SlashCommand,
52    FileReference,
53    InlineCode,
54}
55
56/// A contiguous range of characters sharing the same token kind.
57struct InputToken {
58    kind: InputTokenKind,
59    /// Start char index (inclusive).
60    start: usize,
61    /// End char index (exclusive).
62    end: usize,
63}
64
65/// Tokenize input text into styled regions for syntax highlighting.
66fn tokenize_input(content: &str) -> Vec<InputToken> {
67    let chars: Vec<char> = content.chars().collect();
68    let len = chars.len();
69    if len == 0 {
70        return Vec::new();
71    }
72
73    // Assign a token kind to each character position.
74    let mut kinds = vec![InputTokenKind::Normal; len];
75
76    // 1. Slash commands: `/word` at the start or after whitespace.
77    //    Mark the leading `/` and subsequent non-whitespace chars.
78    {
79        let mut i = 0;
80        while i < len {
81            if chars[i] == '/'
82                && (i == 0 || chars[i - 1].is_whitespace())
83                && i + 1 < len
84                && chars[i + 1].is_alphanumeric()
85            {
86                let start = i;
87                i += 1;
88                while i < len && !chars[i].is_whitespace() {
89                    i += 1;
90                }
91                for kind in &mut kinds[start..i] {
92                    *kind = InputTokenKind::SlashCommand;
93                }
94                continue;
95            }
96            i += 1;
97        }
98    }
99
100    // 2. @file references: `@` at word boundary followed by non-whitespace.
101    {
102        let mut i = 0;
103        while i < len {
104            if chars[i] == '@'
105                && (i == 0 || chars[i - 1].is_whitespace())
106                && i + 1 < len
107                && !chars[i + 1].is_whitespace()
108            {
109                let start = i;
110                i += 1;
111                while i < len && !chars[i].is_whitespace() {
112                    i += 1;
113                }
114                for kind in &mut kinds[start..i] {
115                    *kind = InputTokenKind::FileReference;
116                }
117                continue;
118            }
119            i += 1;
120        }
121    }
122
123    // 3. Inline code: backtick-delimited spans (single or triple).
124    {
125        let mut i = 0;
126        while i < len {
127            if chars[i] == '`' {
128                let tick_start = i;
129                let mut tick_len = 0;
130                while i < len && chars[i] == '`' {
131                    tick_len += 1;
132                    i += 1;
133                }
134                // Find matching closing backticks.
135                let mut found = false;
136                let content_start = i;
137                while i <= len.saturating_sub(tick_len) {
138                    if chars[i] == '`' {
139                        let mut close_len = 0;
140                        while i < len && chars[i] == '`' {
141                            close_len += 1;
142                            i += 1;
143                        }
144                        if close_len == tick_len {
145                            for kind in &mut kinds[tick_start..i] {
146                                *kind = InputTokenKind::InlineCode;
147                            }
148                            found = true;
149                            break;
150                        }
151                    } else {
152                        i += 1;
153                    }
154                }
155                if !found {
156                    i = content_start;
157                }
158                continue;
159            }
160            i += 1;
161        }
162    }
163
164    // Coalesce adjacent chars with the same kind into tokens.
165    let mut tokens = Vec::new();
166    let mut cur_kind = kinds[0];
167    let mut cur_start = 0;
168    for (i, kind) in kinds.iter().enumerate().skip(1) {
169        if *kind != cur_kind {
170            tokens.push(InputToken {
171                kind: cur_kind,
172                start: cur_start,
173                end: i,
174            });
175            cur_kind = *kind;
176            cur_start = i;
177        }
178    }
179    tokens.push(InputToken {
180        kind: cur_kind,
181        start: cur_start,
182        end: len,
183    });
184    tokens
185}
186
187struct InputLayout {
188    buffers: Vec<InputLineBuffer>,
189    cursor_line_idx: usize,
190    cursor_column: u16,
191}
192
193const SHELL_MODE_BORDER_TITLE: &str = " ! Shell mode ";
194const SHELL_MODE_STATUS_HINT: &str = "Shell mode (!): direct command execution";
195
196impl Session {
197    pub(super) fn render_input(&mut self, frame: &mut Frame<'_>, area: Rect) {
198        if area.height == 0 {
199            self.set_input_area(None);
200            return;
201        }
202
203        let mut input_area = area;
204        let mut status_area = None;
205        if area.height > ui::INLINE_INPUT_STATUS_HEIGHT {
206            let block_height = area.height.saturating_sub(ui::INLINE_INPUT_STATUS_HEIGHT);
207            input_area.height = block_height.max(1);
208            status_area = Some(Rect::new(
209                area.x,
210                area.y + block_height,
211                area.width,
212                ui::INLINE_INPUT_STATUS_HEIGHT,
213            ));
214        }
215
216        self.set_input_area(Some(input_area));
217
218        let background_style = self.styles.input_background_style();
219        let shell_mode_title = self.shell_mode_border_title();
220        let mut block = if shell_mode_title.is_some() {
221            Block::bordered()
222        } else {
223            Block::new()
224        };
225        block = block
226            .style(background_style)
227            .padding(self.input_block_padding());
228        if let Some(title) = shell_mode_title {
229            block = block
230                .title(title)
231                .border_type(super::terminal_capabilities::get_border_type())
232                .border_style(self.styles.accent_style().add_modifier(Modifier::BOLD));
233        }
234        let inner = block.inner(input_area);
235        let input_render = self.build_input_render(inner.width, inner.height);
236        let paragraph = Paragraph::new(input_render.text)
237            .style(background_style)
238            .wrap(Wrap { trim: false });
239        frame.render_widget(paragraph.block(block), input_area);
240
241        if self.cursor_should_be_visible() && inner.width > 0 && inner.height > 0 {
242            let cursor_x = input_render
243                .cursor_x
244                .min(inner.width.saturating_sub(1))
245                .saturating_add(inner.x);
246            let cursor_y = input_render
247                .cursor_y
248                .min(inner.height.saturating_sub(1))
249                .saturating_add(inner.y);
250            if self.use_fake_cursor() {
251                render_fake_cursor(frame.buffer_mut(), cursor_x, cursor_y);
252            } else {
253                frame.set_cursor_position(Position::new(cursor_x, cursor_y));
254            }
255        }
256
257        if let Some(status_area) = status_area {
258            let status_line = self
259                .render_input_status_line(status_area.width)
260                .unwrap_or_default();
261            let status = Paragraph::new(status_line)
262                .style(self.styles.default_style())
263                .wrap(Wrap { trim: false });
264            frame.render_widget(status, status_area);
265        }
266    }
267
268    pub(crate) fn desired_input_lines(&self, inner_width: u16) -> u16 {
269        if inner_width == 0 {
270            return 1;
271        }
272
273        if self.input_compact_mode
274            && self.input_manager.cursor() == self.input_manager.content().len()
275            && self.input_compact_placeholder().is_some()
276        {
277            return 1;
278        }
279
280        if self.input_manager.content().is_empty() {
281            return 1;
282        }
283
284        let prompt_width = UnicodeWidthStr::width(self.prompt_prefix.as_str()) as u16;
285        let prompt_display_width = prompt_width.min(inner_width);
286        let layout = self.input_layout(inner_width, prompt_display_width);
287        let line_count = layout.buffers.len().max(1);
288        let capped = line_count.min(ui::INLINE_INPUT_MAX_LINES.max(1));
289        capped as u16
290    }
291
292    pub(crate) fn apply_input_height(&mut self, height: u16) {
293        let resolved = height.max(Self::input_block_height_for_lines(1));
294        if self.input_height != resolved {
295            self.input_height = resolved;
296            crate::ui::tui::session::render::recalculate_transcript_rows(self);
297        }
298    }
299
300    pub(crate) fn input_block_height_for_lines(lines: u16) -> u16 {
301        lines
302            .max(1)
303            .saturating_add(ui::INLINE_INPUT_PADDING_VERTICAL.saturating_mul(2))
304    }
305
306    fn input_layout(&self, width: u16, prompt_display_width: u16) -> InputLayout {
307        let indent_prefix = " ".repeat(prompt_display_width as usize);
308        let mut buffers = vec![InputLineBuffer::new(
309            self.prompt_prefix.clone(),
310            prompt_display_width,
311            0,
312        )];
313        let secure_prompt_active = self.secure_prompt_active();
314        let mut cursor_line_idx = 0usize;
315        let mut cursor_column = prompt_display_width;
316        let input_content = self.input_manager.content();
317        let cursor_pos = self.input_manager.cursor();
318        let mut cursor_set = cursor_pos == 0;
319        let mut char_idx: usize = 0;
320
321        for (idx, ch) in input_content.char_indices() {
322            if !cursor_set
323                && cursor_pos == idx
324                && let Some(current) = buffers.last()
325            {
326                cursor_line_idx = buffers.len() - 1;
327                cursor_column = current.prefix_width + current.text_width;
328                cursor_set = true;
329            }
330
331            if ch == '\n' {
332                let end = idx + ch.len_utf8();
333                char_idx += 1;
334                buffers.push(InputLineBuffer::new(
335                    indent_prefix.clone(),
336                    prompt_display_width,
337                    char_idx,
338                ));
339                if !cursor_set && cursor_pos == end {
340                    cursor_line_idx = buffers.len() - 1;
341                    cursor_column = prompt_display_width;
342                    cursor_set = true;
343                }
344                continue;
345            }
346
347            let display_ch = if secure_prompt_active { '•' } else { ch };
348            let char_width = UnicodeWidthChar::width(display_ch).unwrap_or(0) as u16;
349
350            if let Some(current) = buffers.last_mut() {
351                let capacity = width.saturating_sub(current.prefix_width);
352                if capacity > 0
353                    && current.text_width + char_width > capacity
354                    && !current.text.is_empty()
355                {
356                    buffers.push(InputLineBuffer::new(
357                        indent_prefix.clone(),
358                        prompt_display_width,
359                        char_idx,
360                    ));
361                }
362            }
363
364            if let Some(current) = buffers.last_mut() {
365                current.text.push(display_ch);
366                current.text_width = current.text_width.saturating_add(char_width);
367            }
368
369            char_idx += 1;
370
371            let end = idx + ch.len_utf8();
372            if !cursor_set
373                && cursor_pos == end
374                && let Some(current) = buffers.last()
375            {
376                cursor_line_idx = buffers.len() - 1;
377                cursor_column = current.prefix_width + current.text_width;
378                cursor_set = true;
379            }
380        }
381
382        if !cursor_set && let Some(current) = buffers.last() {
383            cursor_line_idx = buffers.len() - 1;
384            cursor_column = current.prefix_width + current.text_width;
385        }
386
387        InputLayout {
388            buffers,
389            cursor_line_idx,
390            cursor_column,
391        }
392    }
393
394    fn build_input_render(&self, width: u16, height: u16) -> InputRender {
395        if width == 0 || height == 0 {
396            return InputRender {
397                text: Text::default(),
398                cursor_x: 0,
399                cursor_y: 0,
400            };
401        }
402
403        let max_visible_lines = height.max(1).min(ui::INLINE_INPUT_MAX_LINES as u16) as usize;
404
405        let mut prompt_style = self.prompt_style.clone();
406        if prompt_style.color.is_none() {
407            prompt_style.color = self.theme.primary.or(self.theme.foreground);
408        }
409        let prompt_style = ratatui_style_from_inline(&prompt_style, self.theme.foreground);
410        let prompt_width = UnicodeWidthStr::width(self.prompt_prefix.as_str()) as u16;
411        let prompt_display_width = prompt_width.min(width);
412
413        let cursor_at_end = self.input_manager.cursor() == self.input_manager.content().len();
414        if self.input_compact_mode
415            && cursor_at_end
416            && let Some(placeholder) = self.input_compact_placeholder()
417        {
418            let placeholder_style = InlineTextStyle {
419                color: Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
420                bg_color: None,
421                effects: Effects::DIMMED,
422            };
423            let style = ratatui_style_from_inline(
424                &placeholder_style,
425                Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
426            );
427            let placeholder_width = UnicodeWidthStr::width(placeholder.as_str()) as u16;
428            return InputRender {
429                text: Text::from(vec![Line::from(vec![
430                    Span::styled(self.prompt_prefix.clone(), prompt_style),
431                    Span::styled(placeholder, style),
432                ])]),
433                cursor_x: prompt_display_width.saturating_add(placeholder_width),
434                cursor_y: 0,
435            };
436        }
437
438        if self.input_manager.content().is_empty() {
439            let mut spans = Vec::new();
440            spans.push(Span::styled(self.prompt_prefix.clone(), prompt_style));
441
442            if let Some(placeholder) = &self.placeholder {
443                let placeholder_style = self.placeholder_style.clone().unwrap_or(InlineTextStyle {
444                    color: Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
445                    bg_color: None,
446                    effects: Effects::ITALIC,
447                });
448                let style = ratatui_style_from_inline(
449                    &placeholder_style,
450                    Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
451                );
452                spans.push(Span::styled(placeholder.clone(), style));
453            }
454
455            return InputRender {
456                text: Text::from(vec![Line::from(spans)]),
457                cursor_x: prompt_display_width,
458                cursor_y: 0,
459            };
460        }
461
462        let accent_style =
463            ratatui_style_from_inline(&self.styles.accent_inline_style(), self.theme.foreground);
464        let slash_style = accent_style.fg(Color::Yellow).add_modifier(Modifier::BOLD);
465        let file_ref_style = accent_style
466            .fg(Color::Cyan)
467            .add_modifier(Modifier::UNDERLINED);
468        let code_style = accent_style.fg(Color::Green).add_modifier(Modifier::BOLD);
469
470        let layout = self.input_layout(width, prompt_display_width);
471        let tokens = tokenize_input(self.input_manager.content());
472        let total_lines = layout.buffers.len();
473        let visible_limit = max_visible_lines.max(1);
474        let mut start = total_lines.saturating_sub(visible_limit);
475        if layout.cursor_line_idx < start {
476            start = layout.cursor_line_idx.saturating_sub(visible_limit - 1);
477        }
478        let end = (start + visible_limit).min(total_lines);
479        let cursor_y = layout.cursor_line_idx.saturating_sub(start) as u16;
480
481        let mut lines = Vec::new();
482        for buffer in &layout.buffers[start..end] {
483            let mut spans = Vec::new();
484            spans.push(Span::styled(buffer.prefix.clone(), prompt_style));
485            if !buffer.text.is_empty() {
486                let buf_chars: Vec<char> = buffer.text.chars().collect();
487                let buf_len = buf_chars.len();
488                let buf_start = buffer.char_start;
489                let buf_end = buf_start + buf_len;
490
491                let mut pos = 0usize;
492                for token in &tokens {
493                    if token.end <= buf_start || token.start >= buf_end {
494                        continue;
495                    }
496                    let seg_start = token.start.max(buf_start).saturating_sub(buf_start);
497                    let seg_end = token.end.min(buf_end).saturating_sub(buf_start);
498                    if seg_start > pos {
499                        let text: String = buf_chars[pos..seg_start].iter().collect();
500                        spans.push(Span::styled(text, accent_style));
501                    }
502                    let text: String = buf_chars[seg_start..seg_end].iter().collect();
503                    let style = match token.kind {
504                        InputTokenKind::SlashCommand => slash_style,
505                        InputTokenKind::FileReference => file_ref_style,
506                        InputTokenKind::InlineCode => code_style,
507                        InputTokenKind::Normal => accent_style,
508                    };
509                    spans.push(Span::styled(text, style));
510                    pos = seg_end;
511                }
512                if pos < buf_len {
513                    let text: String = buf_chars[pos..].iter().collect();
514                    spans.push(Span::styled(text, accent_style));
515                }
516            }
517            lines.push(Line::from(spans));
518        }
519
520        if lines.is_empty() {
521            lines.push(Line::from(vec![Span::styled(
522                self.prompt_prefix.clone(),
523                prompt_style,
524            )]));
525        }
526
527        InputRender {
528            text: Text::from(lines),
529            cursor_x: layout.cursor_column,
530            cursor_y,
531        }
532    }
533
534    pub(super) fn input_compact_placeholder(&self) -> Option<String> {
535        let content = self.input_manager.content();
536        let trimmed = content.trim();
537        let attachment_count = self.input_manager.attachments().len();
538        if trimmed.is_empty() && attachment_count == 0 {
539            return None;
540        }
541
542        if let Some(label) = compact_image_label(trimmed) {
543            return Some(format!("[Image: {label}]"));
544        }
545
546        if attachment_count > 0 {
547            let label = if attachment_count == 1 {
548                "1 attachment".to_string()
549            } else {
550                format!("{attachment_count} attachments")
551            };
552            if trimmed.is_empty() {
553                return Some(format!("[Image: {label}]"));
554            }
555            if let Some(compact) = compact_image_placeholders(content) {
556                return Some(format!("[Image: {label}] {compact}"));
557            }
558            return Some(format!("[Image: {label}] {trimmed}"));
559        }
560
561        let line_count = content.split('\n').count();
562        if line_count >= ui::INLINE_PASTE_COLLAPSE_LINE_THRESHOLD {
563            let char_count = content.chars().count();
564            return Some(format!("[Pasted Content {char_count} chars]"));
565        }
566
567        if let Some(compact) = compact_image_placeholders(content) {
568            return Some(compact);
569        }
570
571        None
572    }
573
574    fn render_input_status_line(&self, width: u16) -> Option<Line<'static>> {
575        if width == 0 {
576            return None;
577        }
578
579        let mut left = self
580            .input_status_left
581            .as_ref()
582            .map(|value| value.trim().to_owned())
583            .filter(|value| !value.is_empty());
584        let right = self
585            .input_status_right
586            .as_ref()
587            .map(|value| value.trim().to_owned())
588            .filter(|value| !value.is_empty());
589
590        if let Some(shell_hint) = self.shell_mode_status_hint() {
591            left = Some(match left {
592                Some(existing) => format!("{existing} · {shell_hint}"),
593                None => shell_hint.to_string(),
594            });
595        }
596
597        // Build scroll indicator if enabled
598        let scroll_indicator = if ui::SCROLL_INDICATOR_ENABLED {
599            Some(self.build_scroll_indicator())
600        } else {
601            None
602        };
603
604        if left.is_none() && right.is_none() && scroll_indicator.is_none() {
605            return None;
606        }
607
608        let dim_style = self.styles.default_style().add_modifier(Modifier::DIM);
609        let mut spans = Vec::new();
610
611        // Add left content (git status or shimmered activity)
612        if let Some(left_value) = left.as_ref() {
613            if status_requires_shimmer(left_value)
614                && self.appearance.should_animate_progress_status()
615            {
616                spans.extend(shimmer_spans_with_style_at_phase(
617                    left_value,
618                    dim_style,
619                    self.shimmer_state.phase(),
620                ));
621            } else {
622                spans.extend(self.create_git_status_spans(left_value, dim_style));
623            }
624        }
625
626        // Build right side spans (scroll indicator + optional right content)
627        let mut right_spans: Vec<Span<'static>> = Vec::new();
628        if let Some(scroll) = &scroll_indicator {
629            right_spans.push(Span::styled(scroll.clone(), dim_style));
630        }
631        if let Some(right_value) = &right {
632            if !right_spans.is_empty() {
633                right_spans.push(Span::raw(" "));
634            }
635            right_spans.push(Span::styled(right_value.clone(), dim_style));
636        }
637
638        if !right_spans.is_empty() {
639            let left_width: u16 = spans.iter().map(|s| measure_text_width(&s.content)).sum();
640            let right_width: u16 = right_spans
641                .iter()
642                .map(|s| measure_text_width(&s.content))
643                .sum();
644            let padding = width.saturating_sub(left_width + right_width);
645
646            if padding > 0 {
647                spans.push(Span::raw(" ".repeat(padding as usize)));
648            } else if !spans.is_empty() {
649                spans.push(Span::raw(" "));
650            }
651            spans.extend(right_spans);
652        }
653
654        if spans.is_empty() {
655            return None;
656        }
657
658        let mut line = Line::from(spans);
659        // Apply ellipsis truncation to prevent status line from overflowing
660        line = truncate_line_with_ellipsis_if_overflow(line, usize::from(width));
661        Some(line)
662    }
663
664    pub(crate) fn input_uses_shell_prefix(&self) -> bool {
665        self.input_manager.content().trim_start().starts_with('!')
666    }
667
668    pub(crate) fn input_block_padding(&self) -> Padding {
669        if self.input_uses_shell_prefix() {
670            Padding::new(0, 0, 0, 0)
671        } else {
672            Padding::new(
673                ui::INLINE_INPUT_PADDING_HORIZONTAL,
674                ui::INLINE_INPUT_PADDING_HORIZONTAL,
675                ui::INLINE_INPUT_PADDING_VERTICAL,
676                ui::INLINE_INPUT_PADDING_VERTICAL,
677            )
678        }
679    }
680
681    pub(crate) fn shell_mode_border_title(&self) -> Option<&'static str> {
682        self.input_uses_shell_prefix()
683            .then_some(SHELL_MODE_BORDER_TITLE)
684    }
685
686    fn shell_mode_status_hint(&self) -> Option<&'static str> {
687        self.input_uses_shell_prefix()
688            .then_some(SHELL_MODE_STATUS_HINT)
689    }
690
691    /// Build scroll indicator string with percentage
692    fn build_scroll_indicator(&self) -> String {
693        let percent = self.scroll_manager.progress_percent();
694        format!("{} {:>3}%", ui::SCROLL_INDICATOR_FORMAT, percent)
695    }
696
697    #[allow(dead_code)]
698    fn create_git_status_spans(&self, text: &str, default_style: Style) -> Vec<Span<'static>> {
699        if let Some((branch_part, indicator_part)) = text.rsplit_once(" | ") {
700            let mut spans = Vec::new();
701            let branch_trim = branch_part.trim_end();
702            if !branch_trim.is_empty() {
703                spans.push(Span::styled(branch_trim.to_owned(), default_style));
704            }
705            spans.push(Span::raw(" "));
706
707            let indicator_trim = indicator_part.trim();
708            let indicator_style = if indicator_trim == ui::HEADER_GIT_DIRTY_SUFFIX {
709                Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
710            } else if indicator_trim == ui::HEADER_GIT_CLEAN_SUFFIX {
711                Style::default()
712                    .fg(Color::Green)
713                    .add_modifier(Modifier::BOLD)
714            } else {
715                self.styles.accent_style().add_modifier(Modifier::BOLD)
716            };
717
718            spans.push(Span::styled(indicator_trim.to_owned(), indicator_style));
719            spans
720        } else {
721            vec![Span::styled(text.to_owned(), default_style)]
722        }
723    }
724
725    fn cursor_should_be_visible(&self) -> bool {
726        let loading_state = self.is_running_activity() || self.has_status_spinner();
727        self.cursor_visible && (self.input_enabled || loading_state)
728    }
729
730    fn use_fake_cursor(&self) -> bool {
731        self.has_status_spinner()
732    }
733
734    fn secure_prompt_active(&self) -> bool {
735        self.modal
736            .as_ref()
737            .and_then(|modal| modal.secure_prompt.as_ref())
738            .is_some()
739    }
740
741    /// Build input render data for external widgets
742    pub fn build_input_widget_data(&self, width: u16, height: u16) -> InputWidgetData {
743        let input_render = self.build_input_render(width, height);
744        let background_style = self.styles.input_background_style();
745
746        InputWidgetData {
747            text: input_render.text,
748            cursor_x: input_render.cursor_x,
749            cursor_y: input_render.cursor_y,
750            cursor_should_be_visible: self.cursor_should_be_visible(),
751            use_fake_cursor: self.use_fake_cursor(),
752            background_style,
753            default_style: self.styles.default_style(),
754        }
755    }
756
757    /// Build input status line for external widgets
758    pub fn build_input_status_widget_data(&self, width: u16) -> Option<Vec<Span<'static>>> {
759        self.render_input_status_line(width).map(|line| line.spans)
760    }
761}
762
763fn compact_image_label(content: &str) -> Option<String> {
764    let trimmed = content.trim();
765    if trimmed.is_empty() {
766        return None;
767    }
768
769    let unquoted = trimmed
770        .strip_prefix('"')
771        .and_then(|value| value.strip_suffix('"'))
772        .or_else(|| {
773            trimmed
774                .strip_prefix('\'')
775                .and_then(|value| value.strip_suffix('\''))
776        })
777        .unwrap_or(trimmed);
778
779    if unquoted.starts_with("data:image/") {
780        return Some("inline image".to_string());
781    }
782
783    let windows_drive = unquoted.as_bytes().get(1).is_some_and(|ch| *ch == b':')
784        && unquoted
785            .as_bytes()
786            .get(2)
787            .is_some_and(|ch| *ch == b'\\' || *ch == b'/');
788    let starts_like_path = unquoted.starts_with('@')
789        || unquoted.starts_with("file://")
790        || unquoted.starts_with('/')
791        || unquoted.starts_with("./")
792        || unquoted.starts_with("../")
793        || unquoted.starts_with("~/")
794        || windows_drive;
795    if !starts_like_path {
796        return None;
797    }
798
799    let without_at = unquoted.strip_prefix('@').unwrap_or(unquoted);
800
801    // Skip npm scoped package patterns like @scope/package@version
802    if without_at.contains('/')
803        && !without_at.starts_with('.')
804        && !without_at.starts_with('/')
805        && !without_at.starts_with("~/")
806    {
807        // Check if this looks like @scope/package (npm package)
808        let parts: Vec<&str> = without_at.split('/').collect();
809        if parts.len() >= 2 && !parts[0].is_empty() {
810            // Reject if it looks like a package name (no extension on second component)
811            if !parts[parts.len() - 1].contains('.') {
812                return None;
813            }
814        }
815    }
816
817    let without_scheme = without_at.strip_prefix("file://").unwrap_or(without_at);
818    let path = Path::new(without_scheme);
819    if !is_image_path(path) {
820        return None;
821    }
822
823    let label = path
824        .file_name()
825        .and_then(|name| name.to_str())
826        .unwrap_or(without_scheme);
827    Some(label.to_string())
828}
829
830static IMAGE_PATH_INLINE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
831    match Regex::new(
832        r#"(?ix)
833        (?:^|[\s\(\[\{<\"'`])
834        (
835            @?
836            (?:file://)?
837            (?:
838                ~/(?:[^\n/]+/)+
839              | /(?:[^\n/]+/)+
840              | [A-Za-z]:[\\/](?:[^\n\\\/]+[\\/])+
841            )
842            [^\n]*?
843            \.(?:png|jpe?g|gif|bmp|webp|tiff?|svg)
844        )"#,
845    ) {
846        Ok(regex) => regex,
847        Err(error) => panic!("Failed to compile inline image path regex: {error}"),
848    }
849});
850
851fn compact_image_placeholders(content: &str) -> Option<String> {
852    let mut matches = Vec::new();
853    for capture in IMAGE_PATH_INLINE_REGEX.captures_iter(content) {
854        let Some(path_match) = capture.get(1) else {
855            continue;
856        };
857        let raw = path_match.as_str();
858        let Some(label) = image_label_for_path(raw) else {
859            continue;
860        };
861        matches.push((path_match.start(), path_match.end(), label));
862    }
863
864    if matches.is_empty() {
865        return None;
866    }
867
868    let mut result = String::with_capacity(content.len());
869    let mut last_end = 0usize;
870    for (start, end, label) in matches {
871        if start < last_end {
872            continue;
873        }
874        result.push_str(&content[last_end..start]);
875        result.push_str(&format!("[Image: {label}]"));
876        last_end = end;
877    }
878    if last_end < content.len() {
879        result.push_str(&content[last_end..]);
880    }
881
882    Some(result)
883}
884
885fn image_label_for_path(raw: &str) -> Option<String> {
886    let trimmed = raw.trim_matches(|ch: char| matches!(ch, '"' | '\'')).trim();
887    if trimmed.is_empty() {
888        return None;
889    }
890
891    let without_at = trimmed.strip_prefix('@').unwrap_or(trimmed);
892    let without_scheme = without_at.strip_prefix("file://").unwrap_or(without_at);
893    let unescaped = unescape_whitespace(without_scheme);
894    let path = Path::new(unescaped.as_str());
895    if !is_image_path(path) {
896        return None;
897    }
898
899    let label = path
900        .file_name()
901        .and_then(|name| name.to_str())
902        .unwrap_or(unescaped.as_str());
903    Some(label.to_string())
904}
905
906fn unescape_whitespace(token: &str) -> String {
907    let mut result = String::with_capacity(token.len());
908    let mut chars = token.chars().peekable();
909    while let Some(ch) = chars.next() {
910        if ch == '\\'
911            && let Some(next) = chars.peek()
912            && next.is_ascii_whitespace()
913        {
914            result.push(*next);
915            chars.next();
916            continue;
917        }
918        result.push(ch);
919    }
920    result
921}
922
923fn is_spinner_frame(indicator: &str) -> bool {
924    matches!(
925        indicator,
926        "⠋" | "⠙"
927            | "⠹"
928            | "⠸"
929            | "⠼"
930            | "⠴"
931            | "⠦"
932            | "⠧"
933            | "⠇"
934            | "⠏"
935            | "-"
936            | "\\"
937            | "|"
938            | "/"
939            | "."
940    )
941}
942
943pub(crate) fn status_requires_shimmer(text: &str) -> bool {
944    if text.contains("Running command:")
945        || text.contains("Running tool:")
946        || text.contains("Running:")
947        || text.contains("Running ")
948        || text.contains("Executing ")
949        || text.contains("Press Ctrl+C to cancel")
950    {
951        return true;
952    }
953    let Some((indicator, rest)) = text.split_once(' ') else {
954        return false;
955    };
956    if indicator.chars().count() != 1 || rest.trim().is_empty() {
957        return false;
958    }
959    is_spinner_frame(indicator)
960}
961
962/// Data structure for input widget rendering
963#[derive(Clone, Debug)]
964pub struct InputWidgetData {
965    pub text: Text<'static>,
966    pub cursor_x: u16,
967    pub cursor_y: u16,
968    pub cursor_should_be_visible: bool,
969    pub use_fake_cursor: bool,
970    pub background_style: Style,
971    pub default_style: Style,
972}
973
974fn render_fake_cursor(buf: &mut Buffer, cursor_x: u16, cursor_y: u16) {
975    if let Some(cell) = buf.cell_mut((cursor_x, cursor_y)) {
976        let mut style = cell.style();
977        style = style.add_modifier(Modifier::REVERSED);
978        cell.set_style(style);
979        if cell.symbol().is_empty() {
980            cell.set_symbol(" ");
981        }
982    }
983}
984
985#[cfg(test)]
986mod input_highlight_tests {
987    use super::*;
988
989    fn kinds(input: &str) -> Vec<(InputTokenKind, String)> {
990        tokenize_input(input)
991            .into_iter()
992            .map(|t| {
993                let text: String = input.chars().skip(t.start).take(t.end - t.start).collect();
994                (t.kind, text)
995            })
996            .collect()
997    }
998
999    #[test]
1000    fn slash_command_at_start() {
1001        let tokens = kinds("/use skill-name");
1002        assert_eq!(tokens[0].0, InputTokenKind::SlashCommand);
1003        assert_eq!(tokens[0].1, "/use");
1004        assert_eq!(tokens[1].0, InputTokenKind::Normal);
1005    }
1006
1007    #[test]
1008    fn slash_command_with_following_text() {
1009        let tokens = kinds("/doctor hello");
1010        assert_eq!(tokens[0].0, InputTokenKind::SlashCommand);
1011        assert_eq!(tokens[0].1, "/doctor");
1012        assert_eq!(tokens[1].0, InputTokenKind::Normal);
1013    }
1014
1015    #[test]
1016    fn at_file_reference() {
1017        let tokens = kinds("check @src/main.rs please");
1018        assert_eq!(tokens[0].0, InputTokenKind::Normal);
1019        assert_eq!(tokens[1].0, InputTokenKind::FileReference);
1020        assert_eq!(tokens[1].1, "@src/main.rs");
1021        assert_eq!(tokens[2].0, InputTokenKind::Normal);
1022    }
1023
1024    #[test]
1025    fn inline_backtick_code() {
1026        let tokens = kinds("run `cargo test` now");
1027        assert_eq!(tokens[0].0, InputTokenKind::Normal);
1028        assert_eq!(tokens[1].0, InputTokenKind::InlineCode);
1029        assert_eq!(tokens[1].1, "`cargo test`");
1030        assert_eq!(tokens[2].0, InputTokenKind::Normal);
1031    }
1032
1033    #[test]
1034    fn no_false_slash_mid_word() {
1035        let tokens = kinds("path/to/file");
1036        assert_eq!(tokens.len(), 1);
1037        assert_eq!(tokens[0].0, InputTokenKind::Normal);
1038    }
1039
1040    #[test]
1041    fn empty_input() {
1042        assert!(tokenize_input("").is_empty());
1043    }
1044
1045    #[test]
1046    fn mixed_tokens() {
1047        let tokens = kinds("/use @file.rs `code`");
1048        assert_eq!(tokens[0].0, InputTokenKind::SlashCommand);
1049        assert_eq!(tokens[2].0, InputTokenKind::FileReference);
1050        assert_eq!(tokens[4].0, InputTokenKind::InlineCode);
1051    }
1052}