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 anstyle::{Color as AnsiColorEnum, Effects};
5use ratatui::{
6    buffer::Buffer,
7    prelude::*,
8    widgets::{Block, Padding, Paragraph, Wrap},
9};
10use regex::Regex;
11use std::fmt::Write;
12use std::path::Path;
13use std::sync::LazyLock;
14use tui_shimmer::shimmer_spans_with_style_at_phase;
15use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
16use vtcode_commons::fs::is_image_path;
17
18use super::utils::line_truncation::truncate_line_with_ellipsis_if_overflow;
19
20struct InputRender {
21    text: Text<'static>,
22    cursor_x: u16,
23    cursor_y: u16,
24}
25
26#[derive(Default)]
27struct InputLineBuffer {
28    prefix: String,
29    text: String,
30    prefix_width: u16,
31    text_width: u16,
32    /// Character index in the original input where this buffer's text starts.
33    char_start: usize,
34}
35
36impl InputLineBuffer {
37    fn new(prefix: String, prefix_width: u16, char_start: usize) -> Self {
38        Self {
39            prefix,
40            text: String::new(),
41            prefix_width,
42            text_width: 0,
43            char_start,
44        }
45    }
46}
47
48/// Token type for syntax highlighting in the input field.
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50enum InputTokenKind {
51    Normal,
52    SlashCommand,
53    AgentReference,
54    FileReference,
55    InlineCode,
56}
57
58/// A contiguous range of characters sharing the same token kind.
59struct InputToken {
60    kind: InputTokenKind,
61    /// Start char index (inclusive).
62    start: usize,
63    /// End char index (exclusive).
64    end: usize,
65}
66
67/// Tokenize input text into styled regions for syntax highlighting.
68fn tokenize_input(content: &str) -> Vec<InputToken> {
69    let chars: Vec<char> = content.chars().collect();
70    let len = chars.len();
71    if len == 0 {
72        return Vec::new();
73    }
74
75    // Assign a token kind to each character position.
76    let mut kinds = vec![InputTokenKind::Normal; len];
77
78    // 1. Slash commands: `/word` at the start or after whitespace.
79    //    Mark the leading `/` and subsequent non-whitespace chars.
80    {
81        let mut i = 0;
82        while i < len {
83            if chars[i] == '/'
84                && (i == 0 || chars[i - 1].is_whitespace())
85                && i + 1 < len
86                && chars[i + 1].is_alphanumeric()
87            {
88                let start = i;
89                i += 1;
90                while i < len && !chars[i].is_whitespace() {
91                    i += 1;
92                }
93                for kind in &mut kinds[start..i] {
94                    *kind = InputTokenKind::SlashCommand;
95                }
96                continue;
97            }
98            i += 1;
99        }
100    }
101
102    // 2. @agent references: canonical `@agent-...` mentions.
103    {
104        let mut i = 0;
105        while i < len {
106            if chars[i] == '@'
107                && (i == 0 || chars[i - 1].is_whitespace())
108                && chars[i..].starts_with(&['@', 'a', 'g', 'e', 'n', 't', '-'])
109            {
110                let start = i;
111                i += 1;
112                while i < len && !chars[i].is_whitespace() {
113                    i += 1;
114                }
115                for kind in &mut kinds[start..i] {
116                    *kind = InputTokenKind::AgentReference;
117                }
118                continue;
119            }
120            i += 1;
121        }
122    }
123
124    // 3. @file references: `@` at word boundary followed by non-whitespace.
125    {
126        let mut i = 0;
127        while i < len {
128            if chars[i] == '@'
129                && (i == 0 || chars[i - 1].is_whitespace())
130                && i + 1 < len
131                && !chars[i + 1].is_whitespace()
132            {
133                let start = i;
134                i += 1;
135                while i < len && !chars[i].is_whitespace() {
136                    i += 1;
137                }
138                if kinds[start..i]
139                    .iter()
140                    .all(|kind| *kind == InputTokenKind::Normal)
141                {
142                    for kind in &mut kinds[start..i] {
143                        *kind = InputTokenKind::FileReference;
144                    }
145                }
146                continue;
147            }
148            i += 1;
149        }
150    }
151
152    // 4. Inline code: backtick-delimited spans (single or triple).
153    {
154        let mut i = 0;
155        while i < len {
156            if chars[i] == '`' {
157                let tick_start = i;
158                let mut tick_len = 0;
159                while i < len && chars[i] == '`' {
160                    tick_len += 1;
161                    i += 1;
162                }
163                // Find matching closing backticks.
164                let mut found = false;
165                let content_start = i;
166                while i <= len.saturating_sub(tick_len) {
167                    if chars[i] == '`' {
168                        let mut close_len = 0;
169                        while i < len && chars[i] == '`' {
170                            close_len += 1;
171                            i += 1;
172                        }
173                        if close_len == tick_len {
174                            for kind in &mut kinds[tick_start..i] {
175                                *kind = InputTokenKind::InlineCode;
176                            }
177                            found = true;
178                            break;
179                        }
180                    } else {
181                        i += 1;
182                    }
183                }
184                if !found {
185                    i = content_start;
186                }
187                continue;
188            }
189            i += 1;
190        }
191    }
192
193    // Coalesce adjacent chars with the same kind into tokens.
194    let mut tokens = Vec::new();
195    let mut cur_kind = kinds[0];
196    let mut cur_start = 0;
197    for (i, kind) in kinds.iter().enumerate().skip(1) {
198        if *kind != cur_kind {
199            tokens.push(InputToken {
200                kind: cur_kind,
201                start: cur_start,
202                end: i,
203            });
204            cur_kind = *kind;
205            cur_start = i;
206        }
207    }
208    tokens.push(InputToken {
209        kind: cur_kind,
210        start: cur_start,
211        end: len,
212    });
213    tokens
214}
215
216struct InputLayout {
217    buffers: Vec<InputLineBuffer>,
218    cursor_line_idx: usize,
219    cursor_column: u16,
220}
221
222const SHELL_MODE_BORDER_TITLE: &str = " ! Shell mode ";
223const SHELL_MODE_STATUS_HINT: &str = "Shell mode (!): direct command execution";
224
225impl Session {
226    pub(crate) fn render_input(&mut self, frame: &mut Frame<'_>, area: Rect) {
227        if area.height == 0 {
228            self.set_input_area(None);
229            return;
230        }
231
232        let mut input_area = area;
233        let mut status_area = None;
234        if area.height > ui::INLINE_INPUT_STATUS_HEIGHT {
235            let block_height = area.height.saturating_sub(ui::INLINE_INPUT_STATUS_HEIGHT);
236            input_area.height = block_height.max(1);
237            status_area = Some(Rect::new(
238                area.x,
239                area.y + block_height,
240                area.width,
241                ui::INLINE_INPUT_STATUS_HEIGHT,
242            ));
243        }
244
245        let background_style = self.styles.input_background_style();
246        let shell_mode_title = self.shell_mode_border_title();
247        let active_subagent_title = self.active_subagent_input_title();
248        let active_subagent_border_style = self.active_subagent_input_border_style();
249        let mut block = if shell_mode_title.is_some() || active_subagent_title.is_some() {
250            Block::bordered()
251        } else {
252            Block::new()
253        };
254        block = block
255            .style(background_style)
256            .padding(self.input_block_padding());
257        if shell_mode_title.is_some() || active_subagent_title.is_some() {
258            block = block
259                .border_type(super::terminal_capabilities::get_border_type())
260                .border_style(
261                    active_subagent_border_style
262                        .unwrap_or_else(|| self.styles.accent_style().add_modifier(Modifier::BOLD)),
263                );
264        }
265        if let Some(title) = shell_mode_title {
266            block = block.title_top(Line::from(title).left_aligned());
267        }
268        if let Some(title) = active_subagent_title {
269            block = block.title_top(title);
270        }
271        let inner = block.inner(input_area);
272        self.set_input_area(Some(inner));
273        let input_render = self.build_input_render(inner.width, inner.height);
274        let paragraph = Paragraph::new(input_render.text)
275            .style(background_style)
276            .wrap(Wrap { trim: false });
277        frame.render_widget(paragraph.block(block), input_area);
278        self.apply_input_selection_highlight(frame.buffer_mut(), inner);
279        if self.input_manager.selection_needs_copy() {
280            let _ = self.copy_input_selection_to_clipboard();
281        }
282
283        if self.cursor_should_be_visible() && inner.width > 0 && inner.height > 0 {
284            let cursor_x = input_render
285                .cursor_x
286                .min(inner.width.saturating_sub(1))
287                .saturating_add(inner.x);
288            let cursor_y = input_render
289                .cursor_y
290                .min(inner.height.saturating_sub(1))
291                .saturating_add(inner.y);
292            if self.use_fake_cursor() {
293                render_fake_cursor(frame.buffer_mut(), cursor_x, cursor_y);
294            } else {
295                frame.set_cursor_position(Position::new(cursor_x, cursor_y));
296            }
297        }
298
299        if let Some(status_area) = status_area {
300            let status_line = self
301                .render_input_status_line(status_area.width)
302                .unwrap_or_default();
303            let status = Paragraph::new(status_line)
304                .style(self.styles.default_style())
305                .wrap(Wrap { trim: false });
306            frame.render_widget(status, status_area);
307        }
308    }
309
310    pub(crate) fn desired_input_lines(&self, inner_width: u16) -> u16 {
311        if inner_width == 0 {
312            return 1;
313        }
314
315        if self.input_compact_mode
316            && self.input_manager.cursor() == self.input_manager.content().len()
317            && self.input_compact_placeholder().is_some()
318        {
319            return 1;
320        }
321
322        if self.input_manager.content().is_empty() {
323            return 1;
324        }
325
326        let prompt_width = UnicodeWidthStr::width(self.prompt_prefix.as_str()) as u16;
327        let prompt_display_width = prompt_width.min(inner_width);
328        let layout = self.input_layout(inner_width, prompt_display_width);
329        let line_count = layout.buffers.len().max(1);
330        let capped = line_count.min(ui::INLINE_INPUT_MAX_LINES.max(1));
331        capped as u16
332    }
333
334    pub(crate) fn apply_input_height(&mut self, height: u16) {
335        let resolved = height.max(Self::input_block_height_for_lines(1));
336        if self.input_height != resolved {
337            self.input_height = resolved;
338            self.recalculate_transcript_rows();
339        }
340    }
341
342    pub(crate) fn input_block_height_for_lines(lines: u16) -> u16 {
343        lines
344            .max(1)
345            .saturating_add(ui::INLINE_INPUT_PADDING_VERTICAL.saturating_mul(2))
346    }
347
348    pub(crate) fn input_block_extra_height(&self) -> u16 {
349        if self.active_subagent_input_title().is_some() && !self.input_uses_shell_prefix() {
350            2
351        } else {
352            0
353        }
354    }
355
356    fn input_layout(&self, width: u16, prompt_display_width: u16) -> InputLayout {
357        let indent_prefix = " ".repeat(prompt_display_width as usize);
358        let mut buffers = vec![InputLineBuffer::new(
359            self.prompt_prefix.clone(),
360            prompt_display_width,
361            0,
362        )];
363        let secure_prompt_active = self.secure_prompt_active();
364        let mut cursor_line_idx = 0usize;
365        let mut cursor_column = prompt_display_width;
366        let input_content = self.input_manager.content();
367        let cursor_pos = self.input_manager.cursor();
368        let mut cursor_set = cursor_pos == 0;
369        let mut char_idx: usize = 0;
370
371        for (idx, ch) in input_content.char_indices() {
372            if !cursor_set
373                && cursor_pos == idx
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            if ch == '\n' {
382                let end = idx + ch.len_utf8();
383                char_idx += 1;
384                buffers.push(InputLineBuffer::new(
385                    indent_prefix.clone(),
386                    prompt_display_width,
387                    char_idx,
388                ));
389                if !cursor_set && cursor_pos == end {
390                    cursor_line_idx = buffers.len() - 1;
391                    cursor_column = prompt_display_width;
392                    cursor_set = true;
393                }
394                continue;
395            }
396
397            let display_ch = if secure_prompt_active { '•' } else { ch };
398            let char_width = UnicodeWidthChar::width(display_ch).unwrap_or(0) as u16;
399
400            if let Some(current) = buffers.last_mut() {
401                let capacity = width.saturating_sub(current.prefix_width);
402                if capacity > 0
403                    && current.text_width + char_width > capacity
404                    && !current.text.is_empty()
405                {
406                    buffers.push(InputLineBuffer::new(
407                        indent_prefix.clone(),
408                        prompt_display_width,
409                        char_idx,
410                    ));
411                }
412            }
413
414            if let Some(current) = buffers.last_mut() {
415                current.text.push(display_ch);
416                current.text_width = current.text_width.saturating_add(char_width);
417            }
418
419            char_idx += 1;
420
421            let end = idx + ch.len_utf8();
422            if !cursor_set
423                && cursor_pos == end
424                && let Some(current) = buffers.last()
425            {
426                cursor_line_idx = buffers.len() - 1;
427                cursor_column = current.prefix_width + current.text_width;
428                cursor_set = true;
429            }
430        }
431
432        if !cursor_set && let Some(current) = buffers.last() {
433            cursor_line_idx = buffers.len() - 1;
434            cursor_column = current.prefix_width + current.text_width;
435        }
436
437        InputLayout {
438            buffers,
439            cursor_line_idx,
440            cursor_column,
441        }
442    }
443
444    fn visible_input_window(&self, width: u16, height: u16) -> (InputLayout, usize, usize) {
445        let prompt_width = UnicodeWidthStr::width(self.prompt_prefix.as_str()) as u16;
446        let prompt_display_width = prompt_width.min(width);
447        let layout = self.input_layout(width, prompt_display_width);
448        let total_lines = layout.buffers.len();
449        let visible_limit = height.max(1).min(ui::INLINE_INPUT_MAX_LINES as u16) as usize;
450        let mut start = total_lines.saturating_sub(visible_limit);
451        if layout.cursor_line_idx < start {
452            start = layout.cursor_line_idx.saturating_sub(visible_limit - 1);
453        }
454        let end = (start + visible_limit).min(total_lines);
455        (layout, start, end)
456    }
457
458    fn build_input_render(&self, width: u16, height: u16) -> InputRender {
459        if width == 0 || height == 0 {
460            return InputRender {
461                text: Text::default(),
462                cursor_x: 0,
463                cursor_y: 0,
464            };
465        }
466
467        let max_visible_lines = height.max(1).min(ui::INLINE_INPUT_MAX_LINES as u16) as usize;
468
469        let mut prompt_style = self.prompt_style.clone();
470        if prompt_style.color.is_none() {
471            prompt_style.color = self.theme.primary.or(self.theme.foreground);
472        }
473        if self.suggested_prompt_state.active {
474            prompt_style.color = self
475                .theme
476                .tool_accent
477                .or(self.theme.secondary)
478                .or(self.theme.primary)
479                .or(self.theme.foreground);
480            prompt_style.effects |= Effects::BOLD;
481        }
482        let prompt_style = ratatui_style_from_inline(&prompt_style, self.theme.foreground);
483        let prompt_width = UnicodeWidthStr::width(self.prompt_prefix.as_str()) as u16;
484        let prompt_display_width = prompt_width.min(width);
485
486        let cursor_at_end = self.input_manager.cursor() == self.input_manager.content().len();
487        if self.input_compact_mode
488            && cursor_at_end
489            && let Some(placeholder) = self.input_compact_placeholder()
490        {
491            let placeholder_style = InlineTextStyle {
492                color: Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
493                bg_color: None,
494                effects: Effects::DIMMED,
495            };
496            let style = ratatui_style_from_inline(
497                &placeholder_style,
498                Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
499            );
500            let placeholder_width = UnicodeWidthStr::width(placeholder.as_str()) as u16;
501            return InputRender {
502                text: Text::from(vec![Line::from(vec![
503                    Span::styled(self.prompt_prefix.clone(), prompt_style),
504                    Span::styled(placeholder, style),
505                ])]),
506                cursor_x: prompt_display_width.saturating_add(placeholder_width),
507                cursor_y: 0,
508            };
509        }
510
511        if self.input_manager.content().is_empty() {
512            let mut spans = Vec::new();
513            spans.push(Span::styled(self.prompt_prefix.clone(), prompt_style));
514
515            if let Some(suffix) = self.visible_inline_prompt_suggestion_suffix() {
516                let ghost_style = ratatui_style_from_inline(
517                    &InlineTextStyle {
518                        color: Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
519                        bg_color: None,
520                        effects: Effects::DIMMED | Effects::ITALIC,
521                    },
522                    Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
523                );
524                spans.push(Span::styled(suffix, ghost_style));
525            } else if let Some(placeholder) = &self.placeholder {
526                let placeholder_style = self.placeholder_style.clone().unwrap_or(InlineTextStyle {
527                    color: Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
528                    bg_color: None,
529                    effects: Effects::ITALIC,
530                });
531                let style = ratatui_style_from_inline(
532                    &placeholder_style,
533                    Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
534                );
535                spans.push(Span::styled(placeholder.clone(), style));
536            }
537
538            return InputRender {
539                text: Text::from(vec![Line::from(spans)]),
540                cursor_x: prompt_display_width,
541                cursor_y: 0,
542            };
543        }
544
545        let accent_style =
546            ratatui_style_from_inline(&self.styles.accent_inline_style(), self.theme.foreground);
547        let slash_style = accent_style.fg(Color::Yellow).add_modifier(Modifier::BOLD);
548        let file_ref_style = accent_style
549            .fg(Color::Cyan)
550            .add_modifier(Modifier::UNDERLINED);
551        let code_style = accent_style.fg(Color::Green).add_modifier(Modifier::BOLD);
552
553        let (layout, start, end) = self.visible_input_window(width, max_visible_lines as u16);
554        let tokens = tokenize_input(self.input_manager.content());
555        let cursor_y = layout.cursor_line_idx.saturating_sub(start) as u16;
556
557        let vis_count = end.saturating_sub(start);
558        let mut lines = Vec::with_capacity(vis_count);
559        for buffer in &layout.buffers[start..end] {
560            let mut spans = Vec::with_capacity(4);
561            spans.push(Span::styled(buffer.prefix.clone(), prompt_style));
562            if !buffer.text.is_empty() {
563                let buf_chars: Vec<char> = buffer.text.chars().collect();
564                let buf_len = buf_chars.len();
565                let buf_start = buffer.char_start;
566                let buf_end = buf_start + buf_len;
567
568                let mut pos = 0usize;
569                for token in &tokens {
570                    if token.end <= buf_start || token.start >= buf_end {
571                        continue;
572                    }
573                    let seg_start = token.start.max(buf_start).saturating_sub(buf_start);
574                    let seg_end = token.end.min(buf_end).saturating_sub(buf_start);
575                    if seg_start > pos {
576                        let text: String = buf_chars[pos..seg_start].iter().collect();
577                        spans.push(Span::styled(text, accent_style));
578                    }
579                    let text: String = buf_chars[seg_start..seg_end].iter().collect();
580                    let style = match token.kind {
581                        InputTokenKind::SlashCommand => slash_style,
582                        InputTokenKind::AgentReference | InputTokenKind::FileReference => {
583                            file_ref_style
584                        }
585                        InputTokenKind::InlineCode => code_style,
586                        InputTokenKind::Normal => accent_style,
587                    };
588                    spans.push(Span::styled(text, style));
589                    pos = seg_end;
590                }
591                if pos < buf_len {
592                    let text: String = buf_chars[pos..].iter().collect();
593                    spans.push(Span::styled(text, accent_style));
594                }
595            }
596            lines.push(Line::from(spans));
597        }
598
599        if let Some(suffix) = self.visible_inline_prompt_suggestion_suffix() {
600            let ghost_style = ratatui_style_from_inline(
601                &InlineTextStyle {
602                    color: Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
603                    bg_color: None,
604                    effects: Effects::DIMMED | Effects::ITALIC,
605                },
606                Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
607            );
608            if let Some(line) = lines.get_mut(cursor_y as usize) {
609                line.spans.push(Span::styled(suffix, ghost_style));
610            }
611        }
612
613        if lines.is_empty() {
614            lines.push(Line::from(vec![Span::styled(
615                self.prompt_prefix.clone(),
616                prompt_style,
617            )]));
618        }
619
620        InputRender {
621            text: Text::from(lines),
622            cursor_x: layout.cursor_column,
623            cursor_y,
624        }
625    }
626
627    fn apply_input_selection_highlight(&self, buf: &mut Buffer, area: Rect) {
628        let Some((selection_start, selection_end)) = self.input_manager.selection_range() else {
629            return;
630        };
631        if area.width == 0 || area.height == 0 || selection_start == selection_end {
632            return;
633        }
634
635        let (layout, start, end) = self.visible_input_window(area.width, area.height);
636        let selection_start_char =
637            byte_index_to_char_index(self.input_manager.content(), selection_start);
638        let selection_end_char =
639            byte_index_to_char_index(self.input_manager.content(), selection_end);
640
641        for (row_offset, buffer) in layout.buffers[start..end].iter().enumerate() {
642            let line_char_start = buffer.char_start;
643            let line_char_end = buffer.char_start + buffer.text.chars().count();
644            let highlight_start = selection_start_char.max(line_char_start);
645            let highlight_end = selection_end_char.min(line_char_end);
646            if highlight_start >= highlight_end {
647                continue;
648            }
649
650            let local_start = highlight_start.saturating_sub(line_char_start);
651            let local_end = highlight_end.saturating_sub(line_char_start);
652            let start_x = area
653                .x
654                .saturating_add(buffer.prefix_width)
655                .saturating_add(display_width_for_char_range(&buffer.text, local_start));
656            let end_x = area
657                .x
658                .saturating_add(buffer.prefix_width)
659                .saturating_add(display_width_for_char_range(&buffer.text, local_end));
660            let y = area.y.saturating_add(row_offset as u16);
661
662            for x in start_x..end_x.min(area.x.saturating_add(area.width)) {
663                if let Some(cell) = buf.cell_mut((x, y)) {
664                    let mut style = cell.style();
665                    style = style.add_modifier(Modifier::REVERSED);
666                    cell.set_style(style);
667                    if cell.symbol().is_empty() {
668                        cell.set_symbol(" ");
669                    }
670                }
671            }
672        }
673    }
674
675    pub(crate) fn cursor_index_for_input_point(&self, column: u16, row: u16) -> Option<usize> {
676        let area = self.input_area?;
677        if row < area.y
678            || row >= area.y.saturating_add(area.height)
679            || column < area.x
680            || column >= area.x.saturating_add(area.width)
681        {
682            return None;
683        }
684
685        if self.input_compact_mode
686            && self.input_manager.cursor() == self.input_manager.content().len()
687            && self.input_compact_placeholder().is_some()
688        {
689            return Some(self.input_manager.content().len());
690        }
691
692        let relative_row = row.saturating_sub(area.y);
693        let relative_column = column.saturating_sub(area.x);
694        let (layout, start, end) = self.visible_input_window(area.width, area.height);
695        if start >= end {
696            return Some(0);
697        }
698
699        let line_index = (start + usize::from(relative_row)).min(end.saturating_sub(1));
700        let buffer = layout.buffers.get(line_index)?;
701        if relative_column <= buffer.prefix_width {
702            return Some(char_index_to_byte_index(
703                self.input_manager.content(),
704                buffer.char_start,
705            ));
706        }
707
708        let target_width = relative_column.saturating_sub(buffer.prefix_width);
709        let mut consumed_width = 0u16;
710        let mut char_offset = 0usize;
711        for ch in buffer.text.chars() {
712            let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
713            let next_width = consumed_width.saturating_add(ch_width);
714            if target_width < next_width {
715                break;
716            }
717            consumed_width = next_width;
718            char_offset += 1;
719        }
720
721        let char_index = buffer.char_start.saturating_add(char_offset);
722        Some(char_index_to_byte_index(
723            self.input_manager.content(),
724            char_index,
725        ))
726    }
727
728    pub(crate) fn input_compact_placeholder(&self) -> Option<String> {
729        let content = self.input_manager.content();
730        let trimmed = content.trim();
731        let attachment_count = self.input_manager.attachments().len();
732        if trimmed.is_empty() && attachment_count == 0 {
733            return None;
734        }
735
736        if let Some(label) = compact_image_label(trimmed) {
737            return Some(format!("[Image: {label}]"));
738        }
739
740        if attachment_count > 0 {
741            let label = if attachment_count == 1 {
742                "1 attachment".to_string()
743            } else {
744                format!("{attachment_count} attachments")
745            };
746            if trimmed.is_empty() {
747                return Some(format!("[Image: {label}]"));
748            }
749            if let Some(compact) = compact_image_placeholders(content) {
750                return Some(format!("[Image: {label}] {compact}"));
751            }
752            return Some(format!("[Image: {label}] {trimmed}"));
753        }
754
755        let line_count = content.split('\n').count();
756        if line_count >= ui::INLINE_PASTE_COLLAPSE_LINE_THRESHOLD {
757            let char_count = content.chars().count();
758            return Some(format!("[Pasted Content {char_count} chars]"));
759        }
760
761        if let Some(compact) = compact_image_placeholders(content) {
762            return Some(compact);
763        }
764
765        None
766    }
767
768    pub(crate) fn visible_inline_prompt_suggestion_suffix(&self) -> Option<String> {
769        if !self.input_enabled
770            || self.has_active_overlay()
771            || self.input_compact_mode
772            || self.input_manager.cursor() != self.input_manager.content().len()
773        {
774            return None;
775        }
776
777        let suggestion = self.inline_prompt_suggestion.suggestion.as_deref()?;
778        inline_prompt_suggestion_suffix(self.input_manager.content(), suggestion)
779    }
780
781    pub(crate) fn render_input_status_line(&self, width: u16) -> Option<Line<'static>> {
782        if width == 0 {
783            return None;
784        }
785
786        let mut left = self
787            .copy_notification_text()
788            .map(str::to_owned)
789            .or_else(|| self.status_left_text().map(str::to_owned));
790        let right = self.status_right_text().map(str::to_string);
791
792        if let Some(shell_hint) = self.shell_mode_status_hint() {
793            left = Some(match left {
794                Some(existing) => format!("{existing} · {shell_hint}"),
795                None => shell_hint.to_string(),
796            });
797        }
798        if let Some(local_agents_hint) = self.local_agents_input_status_hint() {
799            left = Some(match left {
800                Some(existing) => format!("{existing} · {local_agents_hint}"),
801                None => local_agents_hint,
802            });
803        }
804
805        let right = match (right, self.vim_state.status_label()) {
806            (Some(existing), Some(vim_label)) => Some(format!("{vim_label} · {existing}")),
807            (None, Some(vim_label)) => Some(vim_label.to_string()),
808            (existing, None) => existing,
809        };
810
811        // Build scroll indicator if enabled
812        let scroll_indicator = if ui::SCROLL_INDICATOR_ENABLED {
813            Some(self.build_scroll_indicator())
814        } else {
815            None
816        };
817
818        if left.is_none()
819            && right.is_none()
820            && scroll_indicator.is_none()
821            && !self.thinking_spinner.is_active
822        {
823            return None;
824        }
825
826        let dim_style = self.styles.default_style().add_modifier(Modifier::DIM);
827        let mut spans = Vec::new();
828
829        // Add left content (git status or shimmered activity)
830        if let Some(left_value) = left.as_ref() {
831            if status_requires_shimmer(left_value)
832                && self.appearance.should_animate_progress_status()
833            {
834                spans.extend(shimmer_spans_with_style_at_phase(
835                    left_value,
836                    self.styles.accent_style().add_modifier(Modifier::DIM),
837                    self.shimmer_state.phase(),
838                ));
839            } else {
840                spans.extend(self.create_git_status_spans(left_value, dim_style));
841            }
842        } else if self.thinking_spinner.is_active {
843            spans.push(Span::styled(
844                self.thinking_spinner.current_frame(),
845                dim_style,
846            ));
847            spans.push(Span::raw(" "));
848            spans.push(Span::styled("Thinking", dim_style));
849        }
850
851        // Build right side spans (scroll indicator + optional right content)
852        let mut right_spans: Vec<Span<'static>> = Vec::new();
853        if let Some(scroll) = &scroll_indicator {
854            right_spans.push(Span::styled(scroll.clone(), dim_style));
855        }
856        if let Some(right_value) = &right {
857            if !right_spans.is_empty() {
858                right_spans.push(Span::raw(" "));
859            }
860            right_spans.push(Span::styled(right_value.clone(), dim_style));
861        }
862
863        if !right_spans.is_empty() {
864            let left_width: u16 = spans.iter().map(|s| measure_text_width(&s.content)).sum();
865            let right_width: u16 = right_spans
866                .iter()
867                .map(|s| measure_text_width(&s.content))
868                .sum();
869            let padding = width.saturating_sub(left_width + right_width);
870
871            if padding > 0 {
872                spans.push(Span::raw(" ".repeat(padding as usize)));
873            } else if !spans.is_empty() {
874                spans.push(Span::raw(" "));
875            }
876            spans.extend(right_spans);
877        }
878
879        if spans.is_empty() {
880            return None;
881        }
882
883        let mut line = Line::from(spans);
884        // Apply ellipsis truncation to prevent status line from overflowing
885        line = truncate_line_with_ellipsis_if_overflow(line, usize::from(width));
886        Some(line)
887    }
888
889    pub(crate) fn input_uses_shell_prefix(&self) -> bool {
890        self.input_manager.content().trim_start().starts_with('!')
891    }
892
893    pub(crate) fn input_block_padding(&self) -> Padding {
894        if self.input_uses_shell_prefix() {
895            Padding::new(0, 0, 0, 0)
896        } else {
897            Padding::new(
898                ui::INLINE_INPUT_PADDING_HORIZONTAL,
899                ui::INLINE_INPUT_PADDING_HORIZONTAL,
900                ui::INLINE_INPUT_PADDING_VERTICAL,
901                ui::INLINE_INPUT_PADDING_VERTICAL,
902            )
903        }
904    }
905
906    pub(crate) fn shell_mode_border_title(&self) -> Option<&'static str> {
907        self.input_uses_shell_prefix()
908            .then_some(SHELL_MODE_BORDER_TITLE)
909    }
910
911    pub(crate) fn active_subagent_input_title(&self) -> Option<Line<'static>> {
912        let badge = self.header_context.subagent_badges.first()?;
913        let hidden = self.header_context.subagent_badges.len().saturating_sub(1);
914        let label = if hidden == 0 {
915            badge.text.clone()
916        } else {
917            format!("{} +{}", badge.text, hidden)
918        };
919
920        let mut style = ratatui_style_from_inline(&badge.style, self.theme.foreground);
921        if badge.full_background {
922            style = style.add_modifier(Modifier::BOLD);
923        }
924
925        Some(Line::from(Span::styled(format!(" {label} "), style)).right_aligned())
926    }
927
928    fn active_subagent_input_border_style(&self) -> Option<Style> {
929        let badge = self.header_context.subagent_badges.first()?;
930        let mut title_style = ratatui_style_from_inline(&badge.style, self.theme.foreground);
931        if badge.full_background {
932            title_style = title_style.add_modifier(Modifier::BOLD);
933        }
934
935        let color = if badge.full_background {
936            title_style.bg.or(title_style.fg)
937        } else {
938            title_style.fg.or(title_style.bg)
939        }?;
940
941        Some(
942            self.styles
943                .accent_style()
944                .fg(color)
945                .add_modifier(Modifier::BOLD),
946        )
947    }
948
949    fn shell_mode_status_hint(&self) -> Option<&'static str> {
950        self.input_uses_shell_prefix()
951            .then_some(SHELL_MODE_STATUS_HINT)
952    }
953
954    fn local_agents_input_status_hint(&self) -> Option<String> {
955        if self.input_uses_shell_prefix() || !self.input_manager.content().trim().is_empty() {
956            return None;
957        }
958
959        if !self.has_delegated_local_agents() {
960            return None;
961        }
962
963        Some("↓ or Alt+S local agents · Ctrl+B background".to_string())
964    }
965
966    /// Build scroll indicator string with percentage
967    fn build_scroll_indicator(&self) -> String {
968        let percent = self.scroll_manager.progress_percent();
969        format!("{} {:>3}%", ui::SCROLL_INDICATOR_FORMAT, percent)
970    }
971
972    #[expect(dead_code)]
973    fn create_git_status_spans(&self, text: &str, default_style: Style) -> Vec<Span<'static>> {
974        if let Some((branch_part, indicator_part)) = text.rsplit_once(" | ") {
975            let mut spans = Vec::new();
976            let branch_trim = branch_part.trim_end();
977            if !branch_trim.is_empty() {
978                spans.push(Span::styled(branch_trim.to_owned(), default_style));
979            }
980            spans.push(Span::raw(" "));
981
982            let indicator_trim = indicator_part.trim();
983            let indicator_style = if indicator_trim == ui::HEADER_GIT_DIRTY_SUFFIX {
984                Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
985            } else if indicator_trim == ui::HEADER_GIT_CLEAN_SUFFIX {
986                Style::default()
987                    .fg(Color::Green)
988                    .add_modifier(Modifier::BOLD)
989            } else {
990                self.styles.accent_style().add_modifier(Modifier::BOLD)
991            };
992
993            spans.push(Span::styled(indicator_trim.to_owned(), indicator_style));
994            spans
995        } else {
996            vec![Span::styled(text.to_owned(), default_style)]
997        }
998    }
999
1000    fn cursor_should_be_visible(&self) -> bool {
1001        let loading_state = self.is_running_activity() || self.has_status_spinner();
1002        self.cursor_visible && (self.input_enabled || loading_state)
1003    }
1004
1005    fn use_fake_cursor(&self) -> bool {
1006        self.has_status_spinner()
1007    }
1008
1009    fn secure_prompt_active(&self) -> bool {
1010        self.modal_state()
1011            .and_then(|modal| modal.secure_prompt.as_ref())
1012            .is_some()
1013    }
1014
1015    /// Build input render data for external widgets
1016    pub fn build_input_widget_data(&self, width: u16, height: u16) -> InputWidgetData {
1017        let input_render = self.build_input_render(width, height);
1018        let background_style = self.styles.input_background_style();
1019
1020        InputWidgetData {
1021            text: input_render.text,
1022            cursor_x: input_render.cursor_x,
1023            cursor_y: input_render.cursor_y,
1024            cursor_should_be_visible: self.cursor_should_be_visible(),
1025            use_fake_cursor: self.use_fake_cursor(),
1026            background_style,
1027            default_style: self.styles.default_style(),
1028        }
1029    }
1030
1031    /// Build input status line for external widgets
1032    pub fn build_input_status_widget_data(&self, width: u16) -> Option<Vec<Span<'static>>> {
1033        self.render_input_status_line(width).map(|line| line.spans)
1034    }
1035}
1036
1037fn inline_prompt_suggestion_suffix(current: &str, suggestion: &str) -> Option<String> {
1038    if current.trim().is_empty() {
1039        return Some(suggestion.to_string());
1040    }
1041
1042    let suggestion_lower = suggestion.to_lowercase();
1043    let current_lower = current.to_lowercase();
1044    if !suggestion_lower.starts_with(&current_lower) {
1045        return None;
1046    }
1047
1048    Some(suggestion.chars().skip(current.chars().count()).collect())
1049}
1050
1051fn compact_image_label(content: &str) -> Option<String> {
1052    let trimmed = content.trim();
1053    if trimmed.is_empty() {
1054        return None;
1055    }
1056
1057    let unquoted = trimmed
1058        .strip_prefix('"')
1059        .and_then(|value| value.strip_suffix('"'))
1060        .or_else(|| {
1061            trimmed
1062                .strip_prefix('\'')
1063                .and_then(|value| value.strip_suffix('\''))
1064        })
1065        .unwrap_or(trimmed);
1066
1067    if unquoted.starts_with("data:image/") {
1068        return Some("inline image".to_string());
1069    }
1070
1071    let windows_drive = unquoted.as_bytes().get(1).is_some_and(|ch| *ch == b':')
1072        && unquoted
1073            .as_bytes()
1074            .get(2)
1075            .is_some_and(|ch| *ch == b'\\' || *ch == b'/');
1076    let starts_like_path = unquoted.starts_with('@')
1077        || unquoted.starts_with("file://")
1078        || unquoted.starts_with('/')
1079        || unquoted.starts_with("./")
1080        || unquoted.starts_with("../")
1081        || unquoted.starts_with("~/")
1082        || windows_drive;
1083    if !starts_like_path {
1084        return None;
1085    }
1086
1087    let without_at = unquoted.strip_prefix('@').unwrap_or(unquoted);
1088
1089    // Skip npm scoped package patterns like @scope/package@version
1090    if without_at.contains('/')
1091        && !without_at.starts_with('.')
1092        && !without_at.starts_with('/')
1093        && !without_at.starts_with("~/")
1094    {
1095        // Check if this looks like @scope/package (npm package)
1096        let parts: Vec<&str> = without_at.split('/').collect();
1097        if parts.len() >= 2 && !parts[0].is_empty() {
1098            // Reject if it looks like a package name (no extension on second component)
1099            if !parts[parts.len() - 1].contains('.') {
1100                return None;
1101            }
1102        }
1103    }
1104
1105    let without_scheme = without_at.strip_prefix("file://").unwrap_or(without_at);
1106    let path = Path::new(without_scheme);
1107    if !is_image_path(path) {
1108        return None;
1109    }
1110
1111    let label = path
1112        .file_name()
1113        .and_then(|name| name.to_str())
1114        .unwrap_or(without_scheme);
1115    Some(label.to_string())
1116}
1117
1118static IMAGE_PATH_INLINE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
1119    match Regex::new(
1120        r#"(?ix)
1121        (?:^|[\s\(\[\{<\"'`])
1122        (
1123            @?
1124            (?:file://)?
1125            (?:
1126                ~/(?:[^\n/]+/)+
1127              | /(?:[^\n/]+/)+
1128              | [A-Za-z]:[\\/](?:[^\n\\\/]+[\\/])+
1129            )
1130            [^\n]*?
1131            \.(?:png|jpe?g|gif|bmp|webp|tiff?|svg)
1132        )"#,
1133    ) {
1134        Ok(regex) => regex,
1135        Err(error) => panic!("Failed to compile inline image path regex: {error}"),
1136    }
1137});
1138
1139fn compact_image_placeholders(content: &str) -> Option<String> {
1140    let mut matches = Vec::new();
1141    for capture in IMAGE_PATH_INLINE_REGEX.captures_iter(content) {
1142        let Some(path_match) = capture.get(1) else {
1143            continue;
1144        };
1145        let raw = path_match.as_str();
1146        let Some(label) = image_label_for_path(raw) else {
1147            continue;
1148        };
1149        matches.push((path_match.start(), path_match.end(), label));
1150    }
1151
1152    if matches.is_empty() {
1153        return None;
1154    }
1155
1156    let mut result = String::with_capacity(content.len());
1157    let mut last_end = 0usize;
1158    for (start, end, label) in matches {
1159        if start < last_end {
1160            continue;
1161        }
1162        result.push_str(&content[last_end..start]);
1163        let _ = write!(result, "[Image: {label}]");
1164        last_end = end;
1165    }
1166    if last_end < content.len() {
1167        result.push_str(&content[last_end..]);
1168    }
1169
1170    Some(result)
1171}
1172
1173fn image_label_for_path(raw: &str) -> Option<String> {
1174    let trimmed = raw.trim_matches(|ch: char| matches!(ch, '"' | '\'')).trim();
1175    if trimmed.is_empty() {
1176        return None;
1177    }
1178
1179    let without_at = trimmed.strip_prefix('@').unwrap_or(trimmed);
1180    let without_scheme = without_at.strip_prefix("file://").unwrap_or(without_at);
1181    let unescaped = unescape_whitespace(without_scheme);
1182    let path = Path::new(unescaped.as_str());
1183    if !is_image_path(path) {
1184        return None;
1185    }
1186
1187    let label = path
1188        .file_name()
1189        .and_then(|name| name.to_str())
1190        .unwrap_or(unescaped.as_str());
1191    Some(label.to_string())
1192}
1193
1194fn unescape_whitespace(token: &str) -> String {
1195    let mut result = String::with_capacity(token.len());
1196    let mut chars = token.chars().peekable();
1197    while let Some(ch) = chars.next() {
1198        if ch == '\\'
1199            && let Some(next) = chars.peek()
1200            && next.is_ascii_whitespace()
1201        {
1202            result.push(*next);
1203            chars.next();
1204            continue;
1205        }
1206        result.push(ch);
1207    }
1208    result
1209}
1210
1211fn is_spinner_frame(indicator: &str) -> bool {
1212    matches!(
1213        indicator,
1214        "⠋" | "⠙"
1215            | "⠹"
1216            | "⠸"
1217            | "⠼"
1218            | "⠴"
1219            | "⠦"
1220            | "⠧"
1221            | "⠇"
1222            | "⠏"
1223            | "-"
1224            | "\\"
1225            | "|"
1226            | "/"
1227            | "."
1228    )
1229}
1230
1231pub(crate) fn status_requires_shimmer(text: &str) -> bool {
1232    let normalized = text.trim().to_ascii_lowercase();
1233
1234    if normalized.contains("running command:")
1235        || normalized.contains("running tool:")
1236        || normalized.contains("running:")
1237        || normalized.contains("running ")
1238        || normalized.contains("executing ")
1239        || normalized.contains("approval required")
1240        || normalized.contains("permission required")
1241        || normalized.contains("action required")
1242        || normalized.contains("input required")
1243        || normalized.contains("waiting for approval")
1244        || normalized.contains("waiting for input")
1245        || normalized.contains("ctrl+c")
1246        || normalized.contains("/stop to stop")
1247    {
1248        return true;
1249    }
1250    let Some((indicator, rest)) = text.split_once(' ') else {
1251        return false;
1252    };
1253    if indicator.chars().count() != 1 || rest.trim().is_empty() {
1254        return false;
1255    }
1256    is_spinner_frame(indicator)
1257}
1258
1259/// Data structure for input widget rendering
1260#[derive(Clone, Debug)]
1261pub struct InputWidgetData {
1262    pub text: Text<'static>,
1263    pub cursor_x: u16,
1264    pub cursor_y: u16,
1265    pub cursor_should_be_visible: bool,
1266    pub use_fake_cursor: bool,
1267    pub background_style: Style,
1268    pub default_style: Style,
1269}
1270
1271fn render_fake_cursor(buf: &mut Buffer, cursor_x: u16, cursor_y: u16) {
1272    if let Some(cell) = buf.cell_mut((cursor_x, cursor_y)) {
1273        let mut style = cell.style();
1274        style = style.add_modifier(Modifier::REVERSED);
1275        cell.set_style(style);
1276        if cell.symbol().is_empty() {
1277            cell.set_symbol(" ");
1278        }
1279    }
1280}
1281
1282fn char_index_to_byte_index(content: &str, char_index: usize) -> usize {
1283    if char_index == 0 {
1284        return 0;
1285    }
1286
1287    content
1288        .char_indices()
1289        .nth(char_index)
1290        .map(|(byte_index, _)| byte_index)
1291        .unwrap_or(content.len())
1292}
1293
1294fn byte_index_to_char_index(content: &str, byte_index: usize) -> usize {
1295    content[..byte_index.min(content.len())].chars().count()
1296}
1297
1298fn display_width_for_char_range(content: &str, char_count: usize) -> u16 {
1299    content
1300        .chars()
1301        .take(char_count)
1302        .map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0) as u16)
1303        .fold(0_u16, u16::saturating_add)
1304}
1305
1306#[cfg(test)]
1307mod input_highlight_tests {
1308    use super::*;
1309
1310    fn kinds(input: &str) -> Vec<(InputTokenKind, String)> {
1311        tokenize_input(input)
1312            .into_iter()
1313            .map(|t| {
1314                let text: String = input.chars().skip(t.start).take(t.end - t.start).collect();
1315                (t.kind, text)
1316            })
1317            .collect()
1318    }
1319
1320    #[test]
1321    fn slash_command_at_start() {
1322        let tokens = kinds("/use skill-name");
1323        assert_eq!(tokens[0].0, InputTokenKind::SlashCommand);
1324        assert_eq!(tokens[0].1, "/use");
1325        assert_eq!(tokens[1].0, InputTokenKind::Normal);
1326    }
1327
1328    #[test]
1329    fn slash_command_with_following_text() {
1330        let tokens = kinds("/doctor hello");
1331        assert_eq!(tokens[0].0, InputTokenKind::SlashCommand);
1332        assert_eq!(tokens[0].1, "/doctor");
1333        assert_eq!(tokens[1].0, InputTokenKind::Normal);
1334    }
1335
1336    #[test]
1337    fn at_file_reference() {
1338        let tokens = kinds("check @src/main.rs please");
1339        assert_eq!(tokens[0].0, InputTokenKind::Normal);
1340        assert_eq!(tokens[1].0, InputTokenKind::FileReference);
1341        assert_eq!(tokens[1].1, "@src/main.rs");
1342        assert_eq!(tokens[2].0, InputTokenKind::Normal);
1343    }
1344
1345    #[test]
1346    fn inline_backtick_code() {
1347        let tokens = kinds("run `cargo test` now");
1348        assert_eq!(tokens[0].0, InputTokenKind::Normal);
1349        assert_eq!(tokens[1].0, InputTokenKind::InlineCode);
1350        assert_eq!(tokens[1].1, "`cargo test`");
1351        assert_eq!(tokens[2].0, InputTokenKind::Normal);
1352    }
1353
1354    #[test]
1355    fn no_false_slash_mid_word() {
1356        let tokens = kinds("path/to/file");
1357        assert_eq!(tokens.len(), 1);
1358        assert_eq!(tokens[0].0, InputTokenKind::Normal);
1359    }
1360
1361    #[test]
1362    fn empty_input() {
1363        assert!(tokenize_input("").is_empty());
1364    }
1365
1366    #[test]
1367    fn mixed_tokens() {
1368        let tokens = kinds("/use @file.rs `code`");
1369        assert_eq!(tokens[0].0, InputTokenKind::SlashCommand);
1370        assert_eq!(tokens[2].0, InputTokenKind::FileReference);
1371        assert_eq!(tokens[4].0, InputTokenKind::InlineCode);
1372    }
1373
1374    #[test]
1375    fn agent_reference_has_dedicated_token_kind() {
1376        let tokens = kinds("use @agent-explorer for this");
1377        assert_eq!(tokens[1].0, InputTokenKind::AgentReference);
1378        assert_eq!(tokens[1].1, "@agent-explorer");
1379    }
1380
1381    #[test]
1382    fn plugin_agent_reference_has_dedicated_token_kind() {
1383        let tokens = kinds("use @agent-github:reviewer for this");
1384        assert_eq!(tokens[1].0, InputTokenKind::AgentReference);
1385        assert_eq!(tokens[1].1, "@agent-github:reviewer");
1386    }
1387}