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