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(crate) 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        let background_style = self.styles.input_background_style();
217        let shell_mode_title = self.shell_mode_border_title();
218        let mut block = if shell_mode_title.is_some() {
219            Block::bordered()
220        } else {
221            Block::new()
222        };
223        block = block
224            .style(background_style)
225            .padding(self.input_block_padding());
226        if let Some(title) = shell_mode_title {
227            block = block
228                .title(title)
229                .border_type(super::terminal_capabilities::get_border_type())
230                .border_style(self.styles.accent_style().add_modifier(Modifier::BOLD));
231        }
232        let inner = block.inner(input_area);
233        self.set_input_area(Some(inner));
234        let input_render = self.build_input_render(inner.width, inner.height);
235        let paragraph = Paragraph::new(input_render.text)
236            .style(background_style)
237            .wrap(Wrap { trim: false });
238        frame.render_widget(paragraph.block(block), input_area);
239        self.apply_input_selection_highlight(frame.buffer_mut(), inner);
240        if self.input_manager.selection_needs_copy() {
241            let _ = self.input_manager.copy_selected_text_to_clipboard();
242        }
243
244        if self.cursor_should_be_visible() && inner.width > 0 && inner.height > 0 {
245            let cursor_x = input_render
246                .cursor_x
247                .min(inner.width.saturating_sub(1))
248                .saturating_add(inner.x);
249            let cursor_y = input_render
250                .cursor_y
251                .min(inner.height.saturating_sub(1))
252                .saturating_add(inner.y);
253            if self.use_fake_cursor() {
254                render_fake_cursor(frame.buffer_mut(), cursor_x, cursor_y);
255            } else {
256                frame.set_cursor_position(Position::new(cursor_x, cursor_y));
257            }
258        }
259
260        if let Some(status_area) = status_area {
261            let status_line = self
262                .render_input_status_line(status_area.width)
263                .unwrap_or_default();
264            let status = Paragraph::new(status_line)
265                .style(self.styles.default_style())
266                .wrap(Wrap { trim: false });
267            frame.render_widget(status, status_area);
268        }
269    }
270
271    pub(crate) fn desired_input_lines(&self, inner_width: u16) -> u16 {
272        if inner_width == 0 {
273            return 1;
274        }
275
276        if self.input_compact_mode
277            && self.input_manager.cursor() == self.input_manager.content().len()
278            && self.input_compact_placeholder().is_some()
279        {
280            return 1;
281        }
282
283        if self.input_manager.content().is_empty() {
284            return 1;
285        }
286
287        let prompt_width = UnicodeWidthStr::width(self.prompt_prefix.as_str()) as u16;
288        let prompt_display_width = prompt_width.min(inner_width);
289        let layout = self.input_layout(inner_width, prompt_display_width);
290        let line_count = layout.buffers.len().max(1);
291        let capped = line_count.min(ui::INLINE_INPUT_MAX_LINES.max(1));
292        capped as u16
293    }
294
295    pub(crate) fn apply_input_height(&mut self, height: u16) {
296        let resolved = height.max(Self::input_block_height_for_lines(1));
297        if self.input_height != resolved {
298            self.input_height = resolved;
299            self.recalculate_transcript_rows();
300        }
301    }
302
303    pub(crate) fn input_block_height_for_lines(lines: u16) -> u16 {
304        lines
305            .max(1)
306            .saturating_add(ui::INLINE_INPUT_PADDING_VERTICAL.saturating_mul(2))
307    }
308
309    fn input_layout(&self, width: u16, prompt_display_width: u16) -> InputLayout {
310        let indent_prefix = " ".repeat(prompt_display_width as usize);
311        let mut buffers = vec![InputLineBuffer::new(
312            self.prompt_prefix.clone(),
313            prompt_display_width,
314            0,
315        )];
316        let secure_prompt_active = self.secure_prompt_active();
317        let mut cursor_line_idx = 0usize;
318        let mut cursor_column = prompt_display_width;
319        let input_content = self.input_manager.content();
320        let cursor_pos = self.input_manager.cursor();
321        let mut cursor_set = cursor_pos == 0;
322        let mut char_idx: usize = 0;
323
324        for (idx, ch) in input_content.char_indices() {
325            if !cursor_set
326                && cursor_pos == idx
327                && let Some(current) = buffers.last()
328            {
329                cursor_line_idx = buffers.len() - 1;
330                cursor_column = current.prefix_width + current.text_width;
331                cursor_set = true;
332            }
333
334            if ch == '\n' {
335                let end = idx + ch.len_utf8();
336                char_idx += 1;
337                buffers.push(InputLineBuffer::new(
338                    indent_prefix.clone(),
339                    prompt_display_width,
340                    char_idx,
341                ));
342                if !cursor_set && cursor_pos == end {
343                    cursor_line_idx = buffers.len() - 1;
344                    cursor_column = prompt_display_width;
345                    cursor_set = true;
346                }
347                continue;
348            }
349
350            let display_ch = if secure_prompt_active { '•' } else { ch };
351            let char_width = UnicodeWidthChar::width(display_ch).unwrap_or(0) as u16;
352
353            if let Some(current) = buffers.last_mut() {
354                let capacity = width.saturating_sub(current.prefix_width);
355                if capacity > 0
356                    && current.text_width + char_width > capacity
357                    && !current.text.is_empty()
358                {
359                    buffers.push(InputLineBuffer::new(
360                        indent_prefix.clone(),
361                        prompt_display_width,
362                        char_idx,
363                    ));
364                }
365            }
366
367            if let Some(current) = buffers.last_mut() {
368                current.text.push(display_ch);
369                current.text_width = current.text_width.saturating_add(char_width);
370            }
371
372            char_idx += 1;
373
374            let end = idx + ch.len_utf8();
375            if !cursor_set
376                && cursor_pos == end
377                && let Some(current) = buffers.last()
378            {
379                cursor_line_idx = buffers.len() - 1;
380                cursor_column = current.prefix_width + current.text_width;
381                cursor_set = true;
382            }
383        }
384
385        if !cursor_set && let Some(current) = buffers.last() {
386            cursor_line_idx = buffers.len() - 1;
387            cursor_column = current.prefix_width + current.text_width;
388        }
389
390        InputLayout {
391            buffers,
392            cursor_line_idx,
393            cursor_column,
394        }
395    }
396
397    fn visible_input_window(&self, width: u16, height: u16) -> (InputLayout, usize, usize) {
398        let prompt_width = UnicodeWidthStr::width(self.prompt_prefix.as_str()) as u16;
399        let prompt_display_width = prompt_width.min(width);
400        let layout = self.input_layout(width, prompt_display_width);
401        let total_lines = layout.buffers.len();
402        let visible_limit = height.max(1).min(ui::INLINE_INPUT_MAX_LINES as u16) as usize;
403        let mut start = total_lines.saturating_sub(visible_limit);
404        if layout.cursor_line_idx < start {
405            start = layout.cursor_line_idx.saturating_sub(visible_limit - 1);
406        }
407        let end = (start + visible_limit).min(total_lines);
408        (layout, start, end)
409    }
410
411    fn build_input_render(&self, width: u16, height: u16) -> InputRender {
412        if width == 0 || height == 0 {
413            return InputRender {
414                text: Text::default(),
415                cursor_x: 0,
416                cursor_y: 0,
417            };
418        }
419
420        let max_visible_lines = height.max(1).min(ui::INLINE_INPUT_MAX_LINES as u16) as usize;
421
422        let mut prompt_style = self.prompt_style.clone();
423        if prompt_style.color.is_none() {
424            prompt_style.color = self.theme.primary.or(self.theme.foreground);
425        }
426        if self.suggested_prompt_state.active {
427            prompt_style.color = self
428                .theme
429                .tool_accent
430                .or(self.theme.secondary)
431                .or(self.theme.primary)
432                .or(self.theme.foreground);
433            prompt_style.effects |= Effects::BOLD;
434        }
435        let prompt_style = ratatui_style_from_inline(&prompt_style, self.theme.foreground);
436        let prompt_width = UnicodeWidthStr::width(self.prompt_prefix.as_str()) as u16;
437        let prompt_display_width = prompt_width.min(width);
438
439        let cursor_at_end = self.input_manager.cursor() == self.input_manager.content().len();
440        if self.input_compact_mode
441            && cursor_at_end
442            && let Some(placeholder) = self.input_compact_placeholder()
443        {
444            let placeholder_style = InlineTextStyle {
445                color: Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
446                bg_color: None,
447                effects: Effects::DIMMED,
448            };
449            let style = ratatui_style_from_inline(
450                &placeholder_style,
451                Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
452            );
453            let placeholder_width = UnicodeWidthStr::width(placeholder.as_str()) as u16;
454            return InputRender {
455                text: Text::from(vec![Line::from(vec![
456                    Span::styled(self.prompt_prefix.clone(), prompt_style),
457                    Span::styled(placeholder, style),
458                ])]),
459                cursor_x: prompt_display_width.saturating_add(placeholder_width),
460                cursor_y: 0,
461            };
462        }
463
464        if self.input_manager.content().is_empty() {
465            let mut spans = Vec::new();
466            spans.push(Span::styled(self.prompt_prefix.clone(), prompt_style));
467
468            if let Some(suffix) = self.visible_inline_prompt_suggestion_suffix() {
469                let ghost_style = ratatui_style_from_inline(
470                    &InlineTextStyle {
471                        color: Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
472                        bg_color: None,
473                        effects: Effects::DIMMED | Effects::ITALIC,
474                    },
475                    Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
476                );
477                spans.push(Span::styled(suffix, ghost_style));
478            } else if let Some(placeholder) = &self.placeholder {
479                let placeholder_style = self.placeholder_style.clone().unwrap_or(InlineTextStyle {
480                    color: Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
481                    bg_color: None,
482                    effects: Effects::ITALIC,
483                });
484                let style = ratatui_style_from_inline(
485                    &placeholder_style,
486                    Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
487                );
488                spans.push(Span::styled(placeholder.clone(), style));
489            }
490
491            return InputRender {
492                text: Text::from(vec![Line::from(spans)]),
493                cursor_x: prompt_display_width,
494                cursor_y: 0,
495            };
496        }
497
498        let accent_style =
499            ratatui_style_from_inline(&self.styles.accent_inline_style(), self.theme.foreground);
500        let slash_style = accent_style.fg(Color::Yellow).add_modifier(Modifier::BOLD);
501        let file_ref_style = accent_style
502            .fg(Color::Cyan)
503            .add_modifier(Modifier::UNDERLINED);
504        let code_style = accent_style.fg(Color::Green).add_modifier(Modifier::BOLD);
505
506        let (layout, start, end) = self.visible_input_window(width, max_visible_lines as u16);
507        let tokens = tokenize_input(self.input_manager.content());
508        let cursor_y = layout.cursor_line_idx.saturating_sub(start) as u16;
509
510        let mut lines = Vec::new();
511        for buffer in &layout.buffers[start..end] {
512            let mut spans = Vec::new();
513            spans.push(Span::styled(buffer.prefix.clone(), prompt_style));
514            if !buffer.text.is_empty() {
515                let buf_chars: Vec<char> = buffer.text.chars().collect();
516                let buf_len = buf_chars.len();
517                let buf_start = buffer.char_start;
518                let buf_end = buf_start + buf_len;
519
520                let mut pos = 0usize;
521                for token in &tokens {
522                    if token.end <= buf_start || token.start >= buf_end {
523                        continue;
524                    }
525                    let seg_start = token.start.max(buf_start).saturating_sub(buf_start);
526                    let seg_end = token.end.min(buf_end).saturating_sub(buf_start);
527                    if seg_start > pos {
528                        let text: String = buf_chars[pos..seg_start].iter().collect();
529                        spans.push(Span::styled(text, accent_style));
530                    }
531                    let text: String = buf_chars[seg_start..seg_end].iter().collect();
532                    let style = match token.kind {
533                        InputTokenKind::SlashCommand => slash_style,
534                        InputTokenKind::FileReference => file_ref_style,
535                        InputTokenKind::InlineCode => code_style,
536                        InputTokenKind::Normal => accent_style,
537                    };
538                    spans.push(Span::styled(text, style));
539                    pos = seg_end;
540                }
541                if pos < buf_len {
542                    let text: String = buf_chars[pos..].iter().collect();
543                    spans.push(Span::styled(text, accent_style));
544                }
545            }
546            lines.push(Line::from(spans));
547        }
548
549        if let Some(suffix) = self.visible_inline_prompt_suggestion_suffix() {
550            let ghost_style = ratatui_style_from_inline(
551                &InlineTextStyle {
552                    color: Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
553                    bg_color: None,
554                    effects: Effects::DIMMED | Effects::ITALIC,
555                },
556                Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
557            );
558            if let Some(line) = lines.get_mut(cursor_y as usize) {
559                line.spans.push(Span::styled(suffix, ghost_style));
560            }
561        }
562
563        if lines.is_empty() {
564            lines.push(Line::from(vec![Span::styled(
565                self.prompt_prefix.clone(),
566                prompt_style,
567            )]));
568        }
569
570        InputRender {
571            text: Text::from(lines),
572            cursor_x: layout.cursor_column,
573            cursor_y,
574        }
575    }
576
577    fn apply_input_selection_highlight(&self, buf: &mut Buffer, area: Rect) {
578        let Some((selection_start, selection_end)) = self.input_manager.selection_range() else {
579            return;
580        };
581        if area.width == 0 || area.height == 0 || selection_start == selection_end {
582            return;
583        }
584
585        let (layout, start, end) = self.visible_input_window(area.width, area.height);
586        let selection_start_char =
587            byte_index_to_char_index(self.input_manager.content(), selection_start);
588        let selection_end_char =
589            byte_index_to_char_index(self.input_manager.content(), selection_end);
590
591        for (row_offset, buffer) in layout.buffers[start..end].iter().enumerate() {
592            let line_char_start = buffer.char_start;
593            let line_char_end = buffer.char_start + buffer.text.chars().count();
594            let highlight_start = selection_start_char.max(line_char_start);
595            let highlight_end = selection_end_char.min(line_char_end);
596            if highlight_start >= highlight_end {
597                continue;
598            }
599
600            let local_start = highlight_start.saturating_sub(line_char_start);
601            let local_end = highlight_end.saturating_sub(line_char_start);
602            let start_x = area
603                .x
604                .saturating_add(buffer.prefix_width)
605                .saturating_add(display_width_for_char_range(&buffer.text, local_start));
606            let end_x = area
607                .x
608                .saturating_add(buffer.prefix_width)
609                .saturating_add(display_width_for_char_range(&buffer.text, local_end));
610            let y = area.y.saturating_add(row_offset as u16);
611
612            for x in start_x..end_x.min(area.x.saturating_add(area.width)) {
613                if let Some(cell) = buf.cell_mut((x, y)) {
614                    let mut style = cell.style();
615                    style = style.add_modifier(Modifier::REVERSED);
616                    cell.set_style(style);
617                    if cell.symbol().is_empty() {
618                        cell.set_symbol(" ");
619                    }
620                }
621            }
622        }
623    }
624
625    pub(crate) fn cursor_index_for_input_point(&self, column: u16, row: u16) -> Option<usize> {
626        let area = self.input_area?;
627        if row < area.y
628            || row >= area.y.saturating_add(area.height)
629            || column < area.x
630            || column >= area.x.saturating_add(area.width)
631        {
632            return None;
633        }
634
635        if self.input_compact_mode
636            && self.input_manager.cursor() == self.input_manager.content().len()
637            && self.input_compact_placeholder().is_some()
638        {
639            return Some(self.input_manager.content().len());
640        }
641
642        let relative_row = row.saturating_sub(area.y);
643        let relative_column = column.saturating_sub(area.x);
644        let (layout, start, end) = self.visible_input_window(area.width, area.height);
645        if start >= end {
646            return Some(0);
647        }
648
649        let line_index = (start + usize::from(relative_row)).min(end.saturating_sub(1));
650        let buffer = layout.buffers.get(line_index)?;
651        if relative_column <= buffer.prefix_width {
652            return Some(char_index_to_byte_index(
653                self.input_manager.content(),
654                buffer.char_start,
655            ));
656        }
657
658        let target_width = relative_column.saturating_sub(buffer.prefix_width);
659        let mut consumed_width = 0u16;
660        let mut char_offset = 0usize;
661        for ch in buffer.text.chars() {
662            let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
663            let next_width = consumed_width.saturating_add(ch_width);
664            if target_width < next_width {
665                break;
666            }
667            consumed_width = next_width;
668            char_offset += 1;
669        }
670
671        let char_index = buffer.char_start.saturating_add(char_offset);
672        Some(char_index_to_byte_index(
673            self.input_manager.content(),
674            char_index,
675        ))
676    }
677
678    pub(crate) fn input_compact_placeholder(&self) -> Option<String> {
679        let content = self.input_manager.content();
680        let trimmed = content.trim();
681        let attachment_count = self.input_manager.attachments().len();
682        if trimmed.is_empty() && attachment_count == 0 {
683            return None;
684        }
685
686        if let Some(label) = compact_image_label(trimmed) {
687            return Some(format!("[Image: {label}]"));
688        }
689
690        if attachment_count > 0 {
691            let label = if attachment_count == 1 {
692                "1 attachment".to_string()
693            } else {
694                format!("{attachment_count} attachments")
695            };
696            if trimmed.is_empty() {
697                return Some(format!("[Image: {label}]"));
698            }
699            if let Some(compact) = compact_image_placeholders(content) {
700                return Some(format!("[Image: {label}] {compact}"));
701            }
702            return Some(format!("[Image: {label}] {trimmed}"));
703        }
704
705        let line_count = content.split('\n').count();
706        if line_count >= ui::INLINE_PASTE_COLLAPSE_LINE_THRESHOLD {
707            let char_count = content.chars().count();
708            return Some(format!("[Pasted Content {char_count} chars]"));
709        }
710
711        if let Some(compact) = compact_image_placeholders(content) {
712            return Some(compact);
713        }
714
715        None
716    }
717
718    pub(crate) fn visible_inline_prompt_suggestion_suffix(&self) -> Option<String> {
719        if !self.input_enabled
720            || self.has_active_overlay()
721            || self.input_compact_mode
722            || self.input_manager.cursor() != self.input_manager.content().len()
723        {
724            return None;
725        }
726
727        let suggestion = self.inline_prompt_suggestion.suggestion.as_deref()?;
728        inline_prompt_suggestion_suffix(self.input_manager.content(), suggestion)
729    }
730
731    fn render_input_status_line(&self, width: u16) -> Option<Line<'static>> {
732        if width == 0 {
733            return None;
734        }
735
736        let mut left = self
737            .input_status_left
738            .as_ref()
739            .map(|value| value.trim().to_owned())
740            .filter(|value| !value.is_empty());
741        let right = self
742            .input_status_right
743            .as_ref()
744            .map(|value| value.trim().to_owned())
745            .filter(|value| !value.is_empty());
746
747        if let Some(shell_hint) = self.shell_mode_status_hint() {
748            left = Some(match left {
749                Some(existing) => format!("{existing} · {shell_hint}"),
750                None => shell_hint.to_string(),
751            });
752        }
753
754        let right = match (right, self.vim_state.status_label()) {
755            (Some(existing), Some(vim_label)) => Some(format!("{vim_label} · {existing}")),
756            (None, Some(vim_label)) => Some(vim_label.to_string()),
757            (existing, None) => existing,
758        };
759
760        // Build scroll indicator if enabled
761        let scroll_indicator = if ui::SCROLL_INDICATOR_ENABLED {
762            Some(self.build_scroll_indicator())
763        } else {
764            None
765        };
766
767        if left.is_none() && right.is_none() && scroll_indicator.is_none() {
768            return None;
769        }
770
771        let dim_style = self.styles.default_style().add_modifier(Modifier::DIM);
772        let mut spans = Vec::new();
773
774        // Add left content (git status or shimmered activity)
775        if let Some(left_value) = left.as_ref() {
776            if status_requires_shimmer(left_value)
777                && self.appearance.should_animate_progress_status()
778            {
779                spans.extend(shimmer_spans_with_style_at_phase(
780                    left_value,
781                    self.styles.accent_style().add_modifier(Modifier::DIM),
782                    self.shimmer_state.phase(),
783                ));
784            } else {
785                spans.extend(self.create_git_status_spans(left_value, dim_style));
786            }
787        }
788
789        // Build right side spans (scroll indicator + optional right content)
790        let mut right_spans: Vec<Span<'static>> = Vec::new();
791        if let Some(scroll) = &scroll_indicator {
792            right_spans.push(Span::styled(scroll.clone(), dim_style));
793        }
794        if let Some(right_value) = &right {
795            if !right_spans.is_empty() {
796                right_spans.push(Span::raw(" "));
797            }
798            right_spans.push(Span::styled(right_value.clone(), dim_style));
799        }
800
801        if !right_spans.is_empty() {
802            let left_width: u16 = spans.iter().map(|s| measure_text_width(&s.content)).sum();
803            let right_width: u16 = right_spans
804                .iter()
805                .map(|s| measure_text_width(&s.content))
806                .sum();
807            let padding = width.saturating_sub(left_width + right_width);
808
809            if padding > 0 {
810                spans.push(Span::raw(" ".repeat(padding as usize)));
811            } else if !spans.is_empty() {
812                spans.push(Span::raw(" "));
813            }
814            spans.extend(right_spans);
815        }
816
817        if spans.is_empty() {
818            return None;
819        }
820
821        let mut line = Line::from(spans);
822        // Apply ellipsis truncation to prevent status line from overflowing
823        line = truncate_line_with_ellipsis_if_overflow(line, usize::from(width));
824        Some(line)
825    }
826
827    pub(crate) fn input_uses_shell_prefix(&self) -> bool {
828        self.input_manager.content().trim_start().starts_with('!')
829    }
830
831    pub(crate) fn input_block_padding(&self) -> Padding {
832        if self.input_uses_shell_prefix() {
833            Padding::new(0, 0, 0, 0)
834        } else {
835            Padding::new(
836                ui::INLINE_INPUT_PADDING_HORIZONTAL,
837                ui::INLINE_INPUT_PADDING_HORIZONTAL,
838                ui::INLINE_INPUT_PADDING_VERTICAL,
839                ui::INLINE_INPUT_PADDING_VERTICAL,
840            )
841        }
842    }
843
844    pub(crate) fn shell_mode_border_title(&self) -> Option<&'static str> {
845        self.input_uses_shell_prefix()
846            .then_some(SHELL_MODE_BORDER_TITLE)
847    }
848
849    fn shell_mode_status_hint(&self) -> Option<&'static str> {
850        self.input_uses_shell_prefix()
851            .then_some(SHELL_MODE_STATUS_HINT)
852    }
853
854    /// Build scroll indicator string with percentage
855    fn build_scroll_indicator(&self) -> String {
856        let percent = self.scroll_manager.progress_percent();
857        format!("{} {:>3}%", ui::SCROLL_INDICATOR_FORMAT, percent)
858    }
859
860    #[allow(dead_code)]
861    fn create_git_status_spans(&self, text: &str, default_style: Style) -> Vec<Span<'static>> {
862        if let Some((branch_part, indicator_part)) = text.rsplit_once(" | ") {
863            let mut spans = Vec::new();
864            let branch_trim = branch_part.trim_end();
865            if !branch_trim.is_empty() {
866                spans.push(Span::styled(branch_trim.to_owned(), default_style));
867            }
868            spans.push(Span::raw(" "));
869
870            let indicator_trim = indicator_part.trim();
871            let indicator_style = if indicator_trim == ui::HEADER_GIT_DIRTY_SUFFIX {
872                Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
873            } else if indicator_trim == ui::HEADER_GIT_CLEAN_SUFFIX {
874                Style::default()
875                    .fg(Color::Green)
876                    .add_modifier(Modifier::BOLD)
877            } else {
878                self.styles.accent_style().add_modifier(Modifier::BOLD)
879            };
880
881            spans.push(Span::styled(indicator_trim.to_owned(), indicator_style));
882            spans
883        } else {
884            vec![Span::styled(text.to_owned(), default_style)]
885        }
886    }
887
888    fn cursor_should_be_visible(&self) -> bool {
889        let loading_state = self.is_running_activity() || self.has_status_spinner();
890        self.cursor_visible && (self.input_enabled || loading_state)
891    }
892
893    fn use_fake_cursor(&self) -> bool {
894        self.has_status_spinner()
895    }
896
897    fn secure_prompt_active(&self) -> bool {
898        self.modal_state()
899            .and_then(|modal| modal.secure_prompt.as_ref())
900            .is_some()
901    }
902
903    /// Build input render data for external widgets
904    pub fn build_input_widget_data(&self, width: u16, height: u16) -> InputWidgetData {
905        let input_render = self.build_input_render(width, height);
906        let background_style = self.styles.input_background_style();
907
908        InputWidgetData {
909            text: input_render.text,
910            cursor_x: input_render.cursor_x,
911            cursor_y: input_render.cursor_y,
912            cursor_should_be_visible: self.cursor_should_be_visible(),
913            use_fake_cursor: self.use_fake_cursor(),
914            background_style,
915            default_style: self.styles.default_style(),
916        }
917    }
918
919    /// Build input status line for external widgets
920    pub fn build_input_status_widget_data(&self, width: u16) -> Option<Vec<Span<'static>>> {
921        self.render_input_status_line(width).map(|line| line.spans)
922    }
923}
924
925fn inline_prompt_suggestion_suffix(current: &str, suggestion: &str) -> Option<String> {
926    if current.trim().is_empty() {
927        return Some(suggestion.to_string());
928    }
929
930    let suggestion_lower = suggestion.to_lowercase();
931    let current_lower = current.to_lowercase();
932    if !suggestion_lower.starts_with(&current_lower) {
933        return None;
934    }
935
936    Some(suggestion.chars().skip(current.chars().count()).collect())
937}
938
939fn compact_image_label(content: &str) -> Option<String> {
940    let trimmed = content.trim();
941    if trimmed.is_empty() {
942        return None;
943    }
944
945    let unquoted = trimmed
946        .strip_prefix('"')
947        .and_then(|value| value.strip_suffix('"'))
948        .or_else(|| {
949            trimmed
950                .strip_prefix('\'')
951                .and_then(|value| value.strip_suffix('\''))
952        })
953        .unwrap_or(trimmed);
954
955    if unquoted.starts_with("data:image/") {
956        return Some("inline image".to_string());
957    }
958
959    let windows_drive = unquoted.as_bytes().get(1).is_some_and(|ch| *ch == b':')
960        && unquoted
961            .as_bytes()
962            .get(2)
963            .is_some_and(|ch| *ch == b'\\' || *ch == b'/');
964    let starts_like_path = unquoted.starts_with('@')
965        || unquoted.starts_with("file://")
966        || unquoted.starts_with('/')
967        || unquoted.starts_with("./")
968        || unquoted.starts_with("../")
969        || unquoted.starts_with("~/")
970        || windows_drive;
971    if !starts_like_path {
972        return None;
973    }
974
975    let without_at = unquoted.strip_prefix('@').unwrap_or(unquoted);
976
977    // Skip npm scoped package patterns like @scope/package@version
978    if without_at.contains('/')
979        && !without_at.starts_with('.')
980        && !without_at.starts_with('/')
981        && !without_at.starts_with("~/")
982    {
983        // Check if this looks like @scope/package (npm package)
984        let parts: Vec<&str> = without_at.split('/').collect();
985        if parts.len() >= 2 && !parts[0].is_empty() {
986            // Reject if it looks like a package name (no extension on second component)
987            if !parts[parts.len() - 1].contains('.') {
988                return None;
989            }
990        }
991    }
992
993    let without_scheme = without_at.strip_prefix("file://").unwrap_or(without_at);
994    let path = Path::new(without_scheme);
995    if !is_image_path(path) {
996        return None;
997    }
998
999    let label = path
1000        .file_name()
1001        .and_then(|name| name.to_str())
1002        .unwrap_or(without_scheme);
1003    Some(label.to_string())
1004}
1005
1006static IMAGE_PATH_INLINE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
1007    match Regex::new(
1008        r#"(?ix)
1009        (?:^|[\s\(\[\{<\"'`])
1010        (
1011            @?
1012            (?:file://)?
1013            (?:
1014                ~/(?:[^\n/]+/)+
1015              | /(?:[^\n/]+/)+
1016              | [A-Za-z]:[\\/](?:[^\n\\\/]+[\\/])+
1017            )
1018            [^\n]*?
1019            \.(?:png|jpe?g|gif|bmp|webp|tiff?|svg)
1020        )"#,
1021    ) {
1022        Ok(regex) => regex,
1023        Err(error) => panic!("Failed to compile inline image path regex: {error}"),
1024    }
1025});
1026
1027fn compact_image_placeholders(content: &str) -> Option<String> {
1028    let mut matches = Vec::new();
1029    for capture in IMAGE_PATH_INLINE_REGEX.captures_iter(content) {
1030        let Some(path_match) = capture.get(1) else {
1031            continue;
1032        };
1033        let raw = path_match.as_str();
1034        let Some(label) = image_label_for_path(raw) else {
1035            continue;
1036        };
1037        matches.push((path_match.start(), path_match.end(), label));
1038    }
1039
1040    if matches.is_empty() {
1041        return None;
1042    }
1043
1044    let mut result = String::with_capacity(content.len());
1045    let mut last_end = 0usize;
1046    for (start, end, label) in matches {
1047        if start < last_end {
1048            continue;
1049        }
1050        result.push_str(&content[last_end..start]);
1051        result.push_str(&format!("[Image: {label}]"));
1052        last_end = end;
1053    }
1054    if last_end < content.len() {
1055        result.push_str(&content[last_end..]);
1056    }
1057
1058    Some(result)
1059}
1060
1061fn image_label_for_path(raw: &str) -> Option<String> {
1062    let trimmed = raw.trim_matches(|ch: char| matches!(ch, '"' | '\'')).trim();
1063    if trimmed.is_empty() {
1064        return None;
1065    }
1066
1067    let without_at = trimmed.strip_prefix('@').unwrap_or(trimmed);
1068    let without_scheme = without_at.strip_prefix("file://").unwrap_or(without_at);
1069    let unescaped = unescape_whitespace(without_scheme);
1070    let path = Path::new(unescaped.as_str());
1071    if !is_image_path(path) {
1072        return None;
1073    }
1074
1075    let label = path
1076        .file_name()
1077        .and_then(|name| name.to_str())
1078        .unwrap_or(unescaped.as_str());
1079    Some(label.to_string())
1080}
1081
1082fn unescape_whitespace(token: &str) -> String {
1083    let mut result = String::with_capacity(token.len());
1084    let mut chars = token.chars().peekable();
1085    while let Some(ch) = chars.next() {
1086        if ch == '\\'
1087            && let Some(next) = chars.peek()
1088            && next.is_ascii_whitespace()
1089        {
1090            result.push(*next);
1091            chars.next();
1092            continue;
1093        }
1094        result.push(ch);
1095    }
1096    result
1097}
1098
1099fn is_spinner_frame(indicator: &str) -> bool {
1100    matches!(
1101        indicator,
1102        "⠋" | "⠙"
1103            | "⠹"
1104            | "⠸"
1105            | "⠼"
1106            | "⠴"
1107            | "⠦"
1108            | "⠧"
1109            | "⠇"
1110            | "⠏"
1111            | "-"
1112            | "\\"
1113            | "|"
1114            | "/"
1115            | "."
1116    )
1117}
1118
1119pub(crate) fn status_requires_shimmer(text: &str) -> bool {
1120    if text.contains("Running command:")
1121        || text.contains("Running tool:")
1122        || text.contains("Running:")
1123        || text.contains("Running ")
1124        || text.contains("Executing ")
1125        || text.contains("Ctrl+C")
1126        || text.contains("/stop to stop")
1127    {
1128        return true;
1129    }
1130    let Some((indicator, rest)) = text.split_once(' ') else {
1131        return false;
1132    };
1133    if indicator.chars().count() != 1 || rest.trim().is_empty() {
1134        return false;
1135    }
1136    is_spinner_frame(indicator)
1137}
1138
1139/// Data structure for input widget rendering
1140#[derive(Clone, Debug)]
1141pub struct InputWidgetData {
1142    pub text: Text<'static>,
1143    pub cursor_x: u16,
1144    pub cursor_y: u16,
1145    pub cursor_should_be_visible: bool,
1146    pub use_fake_cursor: bool,
1147    pub background_style: Style,
1148    pub default_style: Style,
1149}
1150
1151fn render_fake_cursor(buf: &mut Buffer, cursor_x: u16, cursor_y: u16) {
1152    if let Some(cell) = buf.cell_mut((cursor_x, cursor_y)) {
1153        let mut style = cell.style();
1154        style = style.add_modifier(Modifier::REVERSED);
1155        cell.set_style(style);
1156        if cell.symbol().is_empty() {
1157            cell.set_symbol(" ");
1158        }
1159    }
1160}
1161
1162fn char_index_to_byte_index(content: &str, char_index: usize) -> usize {
1163    if char_index == 0 {
1164        return 0;
1165    }
1166
1167    content
1168        .char_indices()
1169        .nth(char_index)
1170        .map(|(byte_index, _)| byte_index)
1171        .unwrap_or(content.len())
1172}
1173
1174fn byte_index_to_char_index(content: &str, byte_index: usize) -> usize {
1175    content[..byte_index.min(content.len())].chars().count()
1176}
1177
1178fn display_width_for_char_range(content: &str, char_count: usize) -> u16 {
1179    content
1180        .chars()
1181        .take(char_count)
1182        .map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0) as u16)
1183        .fold(0_u16, u16::saturating_add)
1184}
1185
1186#[cfg(test)]
1187mod input_highlight_tests {
1188    use super::*;
1189
1190    fn kinds(input: &str) -> Vec<(InputTokenKind, String)> {
1191        tokenize_input(input)
1192            .into_iter()
1193            .map(|t| {
1194                let text: String = input.chars().skip(t.start).take(t.end - t.start).collect();
1195                (t.kind, text)
1196            })
1197            .collect()
1198    }
1199
1200    #[test]
1201    fn slash_command_at_start() {
1202        let tokens = kinds("/use skill-name");
1203        assert_eq!(tokens[0].0, InputTokenKind::SlashCommand);
1204        assert_eq!(tokens[0].1, "/use");
1205        assert_eq!(tokens[1].0, InputTokenKind::Normal);
1206    }
1207
1208    #[test]
1209    fn slash_command_with_following_text() {
1210        let tokens = kinds("/doctor hello");
1211        assert_eq!(tokens[0].0, InputTokenKind::SlashCommand);
1212        assert_eq!(tokens[0].1, "/doctor");
1213        assert_eq!(tokens[1].0, InputTokenKind::Normal);
1214    }
1215
1216    #[test]
1217    fn at_file_reference() {
1218        let tokens = kinds("check @src/main.rs please");
1219        assert_eq!(tokens[0].0, InputTokenKind::Normal);
1220        assert_eq!(tokens[1].0, InputTokenKind::FileReference);
1221        assert_eq!(tokens[1].1, "@src/main.rs");
1222        assert_eq!(tokens[2].0, InputTokenKind::Normal);
1223    }
1224
1225    #[test]
1226    fn inline_backtick_code() {
1227        let tokens = kinds("run `cargo test` now");
1228        assert_eq!(tokens[0].0, InputTokenKind::Normal);
1229        assert_eq!(tokens[1].0, InputTokenKind::InlineCode);
1230        assert_eq!(tokens[1].1, "`cargo test`");
1231        assert_eq!(tokens[2].0, InputTokenKind::Normal);
1232    }
1233
1234    #[test]
1235    fn no_false_slash_mid_word() {
1236        let tokens = kinds("path/to/file");
1237        assert_eq!(tokens.len(), 1);
1238        assert_eq!(tokens[0].0, InputTokenKind::Normal);
1239    }
1240
1241    #[test]
1242    fn empty_input() {
1243        assert!(tokenize_input("").is_empty());
1244    }
1245
1246    #[test]
1247    fn mixed_tokens() {
1248        let tokens = kinds("/use @file.rs `code`");
1249        assert_eq!(tokens[0].0, InputTokenKind::SlashCommand);
1250        assert_eq!(tokens[2].0, InputTokenKind::FileReference);
1251        assert_eq!(tokens[4].0, InputTokenKind::InlineCode);
1252    }
1253}