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 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50enum InputTokenKind {
51 Normal,
52 SlashCommand,
53 AgentReference,
54 FileReference,
55 InlineCode,
56}
57
58struct InputToken {
60 kind: InputTokenKind,
61 start: usize,
63 end: usize,
65}
66
67fn 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 let mut kinds = vec![InputTokenKind::Normal; len];
77
78 {
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 {
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 {
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 {
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 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 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 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 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 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 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 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 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 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(¤t_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 if without_at.contains('/')
1091 && !without_at.starts_with('.')
1092 && !without_at.starts_with('/')
1093 && !without_at.starts_with("~/")
1094 {
1095 let parts: Vec<&str> = without_at.split('/').collect();
1097 if parts.len() >= 2 && !parts[0].is_empty() {
1098 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#[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}