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