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