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