1use 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 char_start: usize,
33}
34
35impl InputLineBuffer {
36 fn new(prefix: String, prefix_width: u16, char_start: usize) -> Self {
37 Self {
38 prefix,
39 text: String::new(),
40 prefix_width,
41 text_width: 0,
42 char_start,
43 }
44 }
45}
46
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49enum InputTokenKind {
50 Normal,
51 SlashCommand,
52 FileReference,
53 InlineCode,
54}
55
56struct InputToken {
58 kind: InputTokenKind,
59 start: usize,
61 end: usize,
63}
64
65fn tokenize_input(content: &str) -> Vec<InputToken> {
67 let chars: Vec<char> = content.chars().collect();
68 let len = chars.len();
69 if len == 0 {
70 return Vec::new();
71 }
72
73 let mut kinds = vec![InputTokenKind::Normal; len];
75
76 {
79 let mut i = 0;
80 while i < len {
81 if chars[i] == '/'
82 && (i == 0 || chars[i - 1].is_whitespace())
83 && i + 1 < len
84 && chars[i + 1].is_alphanumeric()
85 {
86 let start = i;
87 i += 1;
88 while i < len && !chars[i].is_whitespace() {
89 i += 1;
90 }
91 for kind in &mut kinds[start..i] {
92 *kind = InputTokenKind::SlashCommand;
93 }
94 continue;
95 }
96 i += 1;
97 }
98 }
99
100 {
102 let mut i = 0;
103 while i < len {
104 if chars[i] == '@'
105 && (i == 0 || chars[i - 1].is_whitespace())
106 && i + 1 < len
107 && !chars[i + 1].is_whitespace()
108 {
109 let start = i;
110 i += 1;
111 while i < len && !chars[i].is_whitespace() {
112 i += 1;
113 }
114 for kind in &mut kinds[start..i] {
115 *kind = InputTokenKind::FileReference;
116 }
117 continue;
118 }
119 i += 1;
120 }
121 }
122
123 {
125 let mut i = 0;
126 while i < len {
127 if chars[i] == '`' {
128 let tick_start = i;
129 let mut tick_len = 0;
130 while i < len && chars[i] == '`' {
131 tick_len += 1;
132 i += 1;
133 }
134 let mut found = false;
136 let content_start = i;
137 while i <= len.saturating_sub(tick_len) {
138 if chars[i] == '`' {
139 let mut close_len = 0;
140 while i < len && chars[i] == '`' {
141 close_len += 1;
142 i += 1;
143 }
144 if close_len == tick_len {
145 for kind in &mut kinds[tick_start..i] {
146 *kind = InputTokenKind::InlineCode;
147 }
148 found = true;
149 break;
150 }
151 } else {
152 i += 1;
153 }
154 }
155 if !found {
156 i = content_start;
157 }
158 continue;
159 }
160 i += 1;
161 }
162 }
163
164 let mut tokens = Vec::new();
166 let mut cur_kind = kinds[0];
167 let mut cur_start = 0;
168 for (i, kind) in kinds.iter().enumerate().skip(1) {
169 if *kind != cur_kind {
170 tokens.push(InputToken {
171 kind: cur_kind,
172 start: cur_start,
173 end: i,
174 });
175 cur_kind = *kind;
176 cur_start = i;
177 }
178 }
179 tokens.push(InputToken {
180 kind: cur_kind,
181 start: cur_start,
182 end: len,
183 });
184 tokens
185}
186
187struct InputLayout {
188 buffers: Vec<InputLineBuffer>,
189 cursor_line_idx: usize,
190 cursor_column: u16,
191}
192
193const SHELL_MODE_BORDER_TITLE: &str = " ! Shell mode ";
194const SHELL_MODE_STATUS_HINT: &str = "Shell mode (!): direct command execution";
195
196impl Session {
197 pub(super) fn render_input(&mut self, frame: &mut Frame<'_>, area: Rect) {
198 if area.height == 0 {
199 self.set_input_area(None);
200 return;
201 }
202
203 let mut input_area = area;
204 let mut status_area = None;
205 if area.height > ui::INLINE_INPUT_STATUS_HEIGHT {
206 let block_height = area.height.saturating_sub(ui::INLINE_INPUT_STATUS_HEIGHT);
207 input_area.height = block_height.max(1);
208 status_area = Some(Rect::new(
209 area.x,
210 area.y + block_height,
211 area.width,
212 ui::INLINE_INPUT_STATUS_HEIGHT,
213 ));
214 }
215
216 self.set_input_area(Some(input_area));
217
218 let background_style = self.styles.input_background_style();
219 let shell_mode_title = self.shell_mode_border_title();
220 let mut block = if shell_mode_title.is_some() {
221 Block::bordered()
222 } else {
223 Block::new()
224 };
225 block = block
226 .style(background_style)
227 .padding(self.input_block_padding());
228 if let Some(title) = shell_mode_title {
229 block = block
230 .title(title)
231 .border_type(super::terminal_capabilities::get_border_type())
232 .border_style(self.styles.accent_style().add_modifier(Modifier::BOLD));
233 }
234 let inner = block.inner(input_area);
235 let input_render = self.build_input_render(inner.width, inner.height);
236 let paragraph = Paragraph::new(input_render.text)
237 .style(background_style)
238 .wrap(Wrap { trim: false });
239 frame.render_widget(paragraph.block(block), input_area);
240
241 if self.cursor_should_be_visible() && inner.width > 0 && inner.height > 0 {
242 let cursor_x = input_render
243 .cursor_x
244 .min(inner.width.saturating_sub(1))
245 .saturating_add(inner.x);
246 let cursor_y = input_render
247 .cursor_y
248 .min(inner.height.saturating_sub(1))
249 .saturating_add(inner.y);
250 if self.use_fake_cursor() {
251 render_fake_cursor(frame.buffer_mut(), cursor_x, cursor_y);
252 } else {
253 frame.set_cursor_position(Position::new(cursor_x, cursor_y));
254 }
255 }
256
257 if let Some(status_area) = status_area {
258 let status_line = self
259 .render_input_status_line(status_area.width)
260 .unwrap_or_default();
261 let status = Paragraph::new(status_line)
262 .style(self.styles.default_style())
263 .wrap(Wrap { trim: false });
264 frame.render_widget(status, status_area);
265 }
266 }
267
268 pub(crate) fn desired_input_lines(&self, inner_width: u16) -> u16 {
269 if inner_width == 0 {
270 return 1;
271 }
272
273 if self.input_compact_mode
274 && self.input_manager.cursor() == self.input_manager.content().len()
275 && self.input_compact_placeholder().is_some()
276 {
277 return 1;
278 }
279
280 if self.input_manager.content().is_empty() {
281 return 1;
282 }
283
284 let prompt_width = UnicodeWidthStr::width(self.prompt_prefix.as_str()) as u16;
285 let prompt_display_width = prompt_width.min(inner_width);
286 let layout = self.input_layout(inner_width, prompt_display_width);
287 let line_count = layout.buffers.len().max(1);
288 let capped = line_count.min(ui::INLINE_INPUT_MAX_LINES.max(1));
289 capped as u16
290 }
291
292 pub(crate) fn apply_input_height(&mut self, height: u16) {
293 let resolved = height.max(Self::input_block_height_for_lines(1));
294 if self.input_height != resolved {
295 self.input_height = resolved;
296 crate::ui::tui::session::render::recalculate_transcript_rows(self);
297 }
298 }
299
300 pub(crate) fn input_block_height_for_lines(lines: u16) -> u16 {
301 lines
302 .max(1)
303 .saturating_add(ui::INLINE_INPUT_PADDING_VERTICAL.saturating_mul(2))
304 }
305
306 fn input_layout(&self, width: u16, prompt_display_width: u16) -> InputLayout {
307 let indent_prefix = " ".repeat(prompt_display_width as usize);
308 let mut buffers = vec![InputLineBuffer::new(
309 self.prompt_prefix.clone(),
310 prompt_display_width,
311 0,
312 )];
313 let secure_prompt_active = self.secure_prompt_active();
314 let mut cursor_line_idx = 0usize;
315 let mut cursor_column = prompt_display_width;
316 let input_content = self.input_manager.content();
317 let cursor_pos = self.input_manager.cursor();
318 let mut cursor_set = cursor_pos == 0;
319 let mut char_idx: usize = 0;
320
321 for (idx, ch) in input_content.char_indices() {
322 if !cursor_set
323 && cursor_pos == idx
324 && let Some(current) = buffers.last()
325 {
326 cursor_line_idx = buffers.len() - 1;
327 cursor_column = current.prefix_width + current.text_width;
328 cursor_set = true;
329 }
330
331 if ch == '\n' {
332 let end = idx + ch.len_utf8();
333 char_idx += 1;
334 buffers.push(InputLineBuffer::new(
335 indent_prefix.clone(),
336 prompt_display_width,
337 char_idx,
338 ));
339 if !cursor_set && cursor_pos == end {
340 cursor_line_idx = buffers.len() - 1;
341 cursor_column = prompt_display_width;
342 cursor_set = true;
343 }
344 continue;
345 }
346
347 let display_ch = if secure_prompt_active { '•' } else { ch };
348 let char_width = UnicodeWidthChar::width(display_ch).unwrap_or(0) as u16;
349
350 if let Some(current) = buffers.last_mut() {
351 let capacity = width.saturating_sub(current.prefix_width);
352 if capacity > 0
353 && current.text_width + char_width > capacity
354 && !current.text.is_empty()
355 {
356 buffers.push(InputLineBuffer::new(
357 indent_prefix.clone(),
358 prompt_display_width,
359 char_idx,
360 ));
361 }
362 }
363
364 if let Some(current) = buffers.last_mut() {
365 current.text.push(display_ch);
366 current.text_width = current.text_width.saturating_add(char_width);
367 }
368
369 char_idx += 1;
370
371 let end = idx + ch.len_utf8();
372 if !cursor_set
373 && cursor_pos == end
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
382 if !cursor_set && let Some(current) = buffers.last() {
383 cursor_line_idx = buffers.len() - 1;
384 cursor_column = current.prefix_width + current.text_width;
385 }
386
387 InputLayout {
388 buffers,
389 cursor_line_idx,
390 cursor_column,
391 }
392 }
393
394 fn build_input_render(&self, width: u16, height: u16) -> InputRender {
395 if width == 0 || height == 0 {
396 return InputRender {
397 text: Text::default(),
398 cursor_x: 0,
399 cursor_y: 0,
400 };
401 }
402
403 let max_visible_lines = height.max(1).min(ui::INLINE_INPUT_MAX_LINES as u16) as usize;
404
405 let mut prompt_style = self.prompt_style.clone();
406 if prompt_style.color.is_none() {
407 prompt_style.color = self.theme.primary.or(self.theme.foreground);
408 }
409 let prompt_style = ratatui_style_from_inline(&prompt_style, self.theme.foreground);
410 let prompt_width = UnicodeWidthStr::width(self.prompt_prefix.as_str()) as u16;
411 let prompt_display_width = prompt_width.min(width);
412
413 let cursor_at_end = self.input_manager.cursor() == self.input_manager.content().len();
414 if self.input_compact_mode
415 && cursor_at_end
416 && let Some(placeholder) = self.input_compact_placeholder()
417 {
418 let placeholder_style = InlineTextStyle {
419 color: Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
420 bg_color: None,
421 effects: Effects::DIMMED,
422 };
423 let style = ratatui_style_from_inline(
424 &placeholder_style,
425 Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
426 );
427 let placeholder_width = UnicodeWidthStr::width(placeholder.as_str()) as u16;
428 return InputRender {
429 text: Text::from(vec![Line::from(vec![
430 Span::styled(self.prompt_prefix.clone(), prompt_style),
431 Span::styled(placeholder, style),
432 ])]),
433 cursor_x: prompt_display_width.saturating_add(placeholder_width),
434 cursor_y: 0,
435 };
436 }
437
438 if self.input_manager.content().is_empty() {
439 let mut spans = Vec::new();
440 spans.push(Span::styled(self.prompt_prefix.clone(), prompt_style));
441
442 if let Some(placeholder) = &self.placeholder {
443 let placeholder_style = self.placeholder_style.clone().unwrap_or(InlineTextStyle {
444 color: Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
445 bg_color: None,
446 effects: Effects::ITALIC,
447 });
448 let style = ratatui_style_from_inline(
449 &placeholder_style,
450 Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
451 );
452 spans.push(Span::styled(placeholder.clone(), style));
453 }
454
455 return InputRender {
456 text: Text::from(vec![Line::from(spans)]),
457 cursor_x: prompt_display_width,
458 cursor_y: 0,
459 };
460 }
461
462 let accent_style =
463 ratatui_style_from_inline(&self.styles.accent_inline_style(), self.theme.foreground);
464 let slash_style = accent_style.fg(Color::Yellow).add_modifier(Modifier::BOLD);
465 let file_ref_style = accent_style
466 .fg(Color::Cyan)
467 .add_modifier(Modifier::UNDERLINED);
468 let code_style = accent_style.fg(Color::Green).add_modifier(Modifier::BOLD);
469
470 let layout = self.input_layout(width, prompt_display_width);
471 let tokens = tokenize_input(self.input_manager.content());
472 let total_lines = layout.buffers.len();
473 let visible_limit = max_visible_lines.max(1);
474 let mut start = total_lines.saturating_sub(visible_limit);
475 if layout.cursor_line_idx < start {
476 start = layout.cursor_line_idx.saturating_sub(visible_limit - 1);
477 }
478 let end = (start + visible_limit).min(total_lines);
479 let cursor_y = layout.cursor_line_idx.saturating_sub(start) as u16;
480
481 let mut lines = Vec::new();
482 for buffer in &layout.buffers[start..end] {
483 let mut spans = Vec::new();
484 spans.push(Span::styled(buffer.prefix.clone(), prompt_style));
485 if !buffer.text.is_empty() {
486 let buf_chars: Vec<char> = buffer.text.chars().collect();
487 let buf_len = buf_chars.len();
488 let buf_start = buffer.char_start;
489 let buf_end = buf_start + buf_len;
490
491 let mut pos = 0usize;
492 for token in &tokens {
493 if token.end <= buf_start || token.start >= buf_end {
494 continue;
495 }
496 let seg_start = token.start.max(buf_start).saturating_sub(buf_start);
497 let seg_end = token.end.min(buf_end).saturating_sub(buf_start);
498 if seg_start > pos {
499 let text: String = buf_chars[pos..seg_start].iter().collect();
500 spans.push(Span::styled(text, accent_style));
501 }
502 let text: String = buf_chars[seg_start..seg_end].iter().collect();
503 let style = match token.kind {
504 InputTokenKind::SlashCommand => slash_style,
505 InputTokenKind::FileReference => file_ref_style,
506 InputTokenKind::InlineCode => code_style,
507 InputTokenKind::Normal => accent_style,
508 };
509 spans.push(Span::styled(text, style));
510 pos = seg_end;
511 }
512 if pos < buf_len {
513 let text: String = buf_chars[pos..].iter().collect();
514 spans.push(Span::styled(text, accent_style));
515 }
516 }
517 lines.push(Line::from(spans));
518 }
519
520 if lines.is_empty() {
521 lines.push(Line::from(vec![Span::styled(
522 self.prompt_prefix.clone(),
523 prompt_style,
524 )]));
525 }
526
527 InputRender {
528 text: Text::from(lines),
529 cursor_x: layout.cursor_column,
530 cursor_y,
531 }
532 }
533
534 pub(super) fn input_compact_placeholder(&self) -> Option<String> {
535 let content = self.input_manager.content();
536 let trimmed = content.trim();
537 let attachment_count = self.input_manager.attachments().len();
538 if trimmed.is_empty() && attachment_count == 0 {
539 return None;
540 }
541
542 if let Some(label) = compact_image_label(trimmed) {
543 return Some(format!("[Image: {label}]"));
544 }
545
546 if attachment_count > 0 {
547 let label = if attachment_count == 1 {
548 "1 attachment".to_string()
549 } else {
550 format!("{attachment_count} attachments")
551 };
552 if trimmed.is_empty() {
553 return Some(format!("[Image: {label}]"));
554 }
555 if let Some(compact) = compact_image_placeholders(content) {
556 return Some(format!("[Image: {label}] {compact}"));
557 }
558 return Some(format!("[Image: {label}] {trimmed}"));
559 }
560
561 let line_count = content.split('\n').count();
562 if line_count >= ui::INLINE_PASTE_COLLAPSE_LINE_THRESHOLD {
563 let char_count = content.chars().count();
564 return Some(format!("[Pasted Content {char_count} chars]"));
565 }
566
567 if let Some(compact) = compact_image_placeholders(content) {
568 return Some(compact);
569 }
570
571 None
572 }
573
574 fn render_input_status_line(&self, width: u16) -> Option<Line<'static>> {
575 if width == 0 {
576 return None;
577 }
578
579 let mut left = self
580 .input_status_left
581 .as_ref()
582 .map(|value| value.trim().to_owned())
583 .filter(|value| !value.is_empty());
584 let right = self
585 .input_status_right
586 .as_ref()
587 .map(|value| value.trim().to_owned())
588 .filter(|value| !value.is_empty());
589
590 if let Some(shell_hint) = self.shell_mode_status_hint() {
591 left = Some(match left {
592 Some(existing) => format!("{existing} · {shell_hint}"),
593 None => shell_hint.to_string(),
594 });
595 }
596
597 let scroll_indicator = if ui::SCROLL_INDICATOR_ENABLED {
599 Some(self.build_scroll_indicator())
600 } else {
601 None
602 };
603
604 if left.is_none() && right.is_none() && scroll_indicator.is_none() {
605 return None;
606 }
607
608 let dim_style = self.styles.default_style().add_modifier(Modifier::DIM);
609 let mut spans = Vec::new();
610
611 if let Some(left_value) = left.as_ref() {
613 if status_requires_shimmer(left_value)
614 && self.appearance.should_animate_progress_status()
615 {
616 spans.extend(shimmer_spans_with_style_at_phase(
617 left_value,
618 dim_style,
619 self.shimmer_state.phase(),
620 ));
621 } else {
622 spans.extend(self.create_git_status_spans(left_value, dim_style));
623 }
624 }
625
626 let mut right_spans: Vec<Span<'static>> = Vec::new();
628 if let Some(scroll) = &scroll_indicator {
629 right_spans.push(Span::styled(scroll.clone(), dim_style));
630 }
631 if let Some(right_value) = &right {
632 if !right_spans.is_empty() {
633 right_spans.push(Span::raw(" "));
634 }
635 right_spans.push(Span::styled(right_value.clone(), dim_style));
636 }
637
638 if !right_spans.is_empty() {
639 let left_width: u16 = spans.iter().map(|s| measure_text_width(&s.content)).sum();
640 let right_width: u16 = right_spans
641 .iter()
642 .map(|s| measure_text_width(&s.content))
643 .sum();
644 let padding = width.saturating_sub(left_width + right_width);
645
646 if padding > 0 {
647 spans.push(Span::raw(" ".repeat(padding as usize)));
648 } else if !spans.is_empty() {
649 spans.push(Span::raw(" "));
650 }
651 spans.extend(right_spans);
652 }
653
654 if spans.is_empty() {
655 return None;
656 }
657
658 let mut line = Line::from(spans);
659 line = truncate_line_with_ellipsis_if_overflow(line, usize::from(width));
661 Some(line)
662 }
663
664 pub(crate) fn input_uses_shell_prefix(&self) -> bool {
665 self.input_manager.content().trim_start().starts_with('!')
666 }
667
668 pub(crate) fn input_block_padding(&self) -> Padding {
669 if self.input_uses_shell_prefix() {
670 Padding::new(0, 0, 0, 0)
671 } else {
672 Padding::new(
673 ui::INLINE_INPUT_PADDING_HORIZONTAL,
674 ui::INLINE_INPUT_PADDING_HORIZONTAL,
675 ui::INLINE_INPUT_PADDING_VERTICAL,
676 ui::INLINE_INPUT_PADDING_VERTICAL,
677 )
678 }
679 }
680
681 pub(crate) fn shell_mode_border_title(&self) -> Option<&'static str> {
682 self.input_uses_shell_prefix()
683 .then_some(SHELL_MODE_BORDER_TITLE)
684 }
685
686 fn shell_mode_status_hint(&self) -> Option<&'static str> {
687 self.input_uses_shell_prefix()
688 .then_some(SHELL_MODE_STATUS_HINT)
689 }
690
691 fn build_scroll_indicator(&self) -> String {
693 let percent = self.scroll_manager.progress_percent();
694 format!("{} {:>3}%", ui::SCROLL_INDICATOR_FORMAT, percent)
695 }
696
697 #[allow(dead_code)]
698 fn create_git_status_spans(&self, text: &str, default_style: Style) -> Vec<Span<'static>> {
699 if let Some((branch_part, indicator_part)) = text.rsplit_once(" | ") {
700 let mut spans = Vec::new();
701 let branch_trim = branch_part.trim_end();
702 if !branch_trim.is_empty() {
703 spans.push(Span::styled(branch_trim.to_owned(), default_style));
704 }
705 spans.push(Span::raw(" "));
706
707 let indicator_trim = indicator_part.trim();
708 let indicator_style = if indicator_trim == ui::HEADER_GIT_DIRTY_SUFFIX {
709 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
710 } else if indicator_trim == ui::HEADER_GIT_CLEAN_SUFFIX {
711 Style::default()
712 .fg(Color::Green)
713 .add_modifier(Modifier::BOLD)
714 } else {
715 self.styles.accent_style().add_modifier(Modifier::BOLD)
716 };
717
718 spans.push(Span::styled(indicator_trim.to_owned(), indicator_style));
719 spans
720 } else {
721 vec![Span::styled(text.to_owned(), default_style)]
722 }
723 }
724
725 fn cursor_should_be_visible(&self) -> bool {
726 let loading_state = self.is_running_activity() || self.has_status_spinner();
727 self.cursor_visible && (self.input_enabled || loading_state)
728 }
729
730 fn use_fake_cursor(&self) -> bool {
731 self.has_status_spinner()
732 }
733
734 fn secure_prompt_active(&self) -> bool {
735 self.modal
736 .as_ref()
737 .and_then(|modal| modal.secure_prompt.as_ref())
738 .is_some()
739 }
740
741 pub fn build_input_widget_data(&self, width: u16, height: u16) -> InputWidgetData {
743 let input_render = self.build_input_render(width, height);
744 let background_style = self.styles.input_background_style();
745
746 InputWidgetData {
747 text: input_render.text,
748 cursor_x: input_render.cursor_x,
749 cursor_y: input_render.cursor_y,
750 cursor_should_be_visible: self.cursor_should_be_visible(),
751 use_fake_cursor: self.use_fake_cursor(),
752 background_style,
753 default_style: self.styles.default_style(),
754 }
755 }
756
757 pub fn build_input_status_widget_data(&self, width: u16) -> Option<Vec<Span<'static>>> {
759 self.render_input_status_line(width).map(|line| line.spans)
760 }
761}
762
763fn compact_image_label(content: &str) -> Option<String> {
764 let trimmed = content.trim();
765 if trimmed.is_empty() {
766 return None;
767 }
768
769 let unquoted = trimmed
770 .strip_prefix('"')
771 .and_then(|value| value.strip_suffix('"'))
772 .or_else(|| {
773 trimmed
774 .strip_prefix('\'')
775 .and_then(|value| value.strip_suffix('\''))
776 })
777 .unwrap_or(trimmed);
778
779 if unquoted.starts_with("data:image/") {
780 return Some("inline image".to_string());
781 }
782
783 let windows_drive = unquoted.as_bytes().get(1).is_some_and(|ch| *ch == b':')
784 && unquoted
785 .as_bytes()
786 .get(2)
787 .is_some_and(|ch| *ch == b'\\' || *ch == b'/');
788 let starts_like_path = unquoted.starts_with('@')
789 || unquoted.starts_with("file://")
790 || unquoted.starts_with('/')
791 || unquoted.starts_with("./")
792 || unquoted.starts_with("../")
793 || unquoted.starts_with("~/")
794 || windows_drive;
795 if !starts_like_path {
796 return None;
797 }
798
799 let without_at = unquoted.strip_prefix('@').unwrap_or(unquoted);
800
801 if without_at.contains('/')
803 && !without_at.starts_with('.')
804 && !without_at.starts_with('/')
805 && !without_at.starts_with("~/")
806 {
807 let parts: Vec<&str> = without_at.split('/').collect();
809 if parts.len() >= 2 && !parts[0].is_empty() {
810 if !parts[parts.len() - 1].contains('.') {
812 return None;
813 }
814 }
815 }
816
817 let without_scheme = without_at.strip_prefix("file://").unwrap_or(without_at);
818 let path = Path::new(without_scheme);
819 if !is_image_path(path) {
820 return None;
821 }
822
823 let label = path
824 .file_name()
825 .and_then(|name| name.to_str())
826 .unwrap_or(without_scheme);
827 Some(label.to_string())
828}
829
830static IMAGE_PATH_INLINE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
831 match Regex::new(
832 r#"(?ix)
833 (?:^|[\s\(\[\{<\"'`])
834 (
835 @?
836 (?:file://)?
837 (?:
838 ~/(?:[^\n/]+/)+
839 | /(?:[^\n/]+/)+
840 | [A-Za-z]:[\\/](?:[^\n\\\/]+[\\/])+
841 )
842 [^\n]*?
843 \.(?:png|jpe?g|gif|bmp|webp|tiff?|svg)
844 )"#,
845 ) {
846 Ok(regex) => regex,
847 Err(error) => panic!("Failed to compile inline image path regex: {error}"),
848 }
849});
850
851fn compact_image_placeholders(content: &str) -> Option<String> {
852 let mut matches = Vec::new();
853 for capture in IMAGE_PATH_INLINE_REGEX.captures_iter(content) {
854 let Some(path_match) = capture.get(1) else {
855 continue;
856 };
857 let raw = path_match.as_str();
858 let Some(label) = image_label_for_path(raw) else {
859 continue;
860 };
861 matches.push((path_match.start(), path_match.end(), label));
862 }
863
864 if matches.is_empty() {
865 return None;
866 }
867
868 let mut result = String::with_capacity(content.len());
869 let mut last_end = 0usize;
870 for (start, end, label) in matches {
871 if start < last_end {
872 continue;
873 }
874 result.push_str(&content[last_end..start]);
875 result.push_str(&format!("[Image: {label}]"));
876 last_end = end;
877 }
878 if last_end < content.len() {
879 result.push_str(&content[last_end..]);
880 }
881
882 Some(result)
883}
884
885fn image_label_for_path(raw: &str) -> Option<String> {
886 let trimmed = raw.trim_matches(|ch: char| matches!(ch, '"' | '\'')).trim();
887 if trimmed.is_empty() {
888 return None;
889 }
890
891 let without_at = trimmed.strip_prefix('@').unwrap_or(trimmed);
892 let without_scheme = without_at.strip_prefix("file://").unwrap_or(without_at);
893 let unescaped = unescape_whitespace(without_scheme);
894 let path = Path::new(unescaped.as_str());
895 if !is_image_path(path) {
896 return None;
897 }
898
899 let label = path
900 .file_name()
901 .and_then(|name| name.to_str())
902 .unwrap_or(unescaped.as_str());
903 Some(label.to_string())
904}
905
906fn unescape_whitespace(token: &str) -> String {
907 let mut result = String::with_capacity(token.len());
908 let mut chars = token.chars().peekable();
909 while let Some(ch) = chars.next() {
910 if ch == '\\'
911 && let Some(next) = chars.peek()
912 && next.is_ascii_whitespace()
913 {
914 result.push(*next);
915 chars.next();
916 continue;
917 }
918 result.push(ch);
919 }
920 result
921}
922
923fn is_spinner_frame(indicator: &str) -> bool {
924 matches!(
925 indicator,
926 "⠋" | "⠙"
927 | "⠹"
928 | "⠸"
929 | "⠼"
930 | "⠴"
931 | "⠦"
932 | "⠧"
933 | "⠇"
934 | "⠏"
935 | "-"
936 | "\\"
937 | "|"
938 | "/"
939 | "."
940 )
941}
942
943pub(crate) fn status_requires_shimmer(text: &str) -> bool {
944 if text.contains("Running command:")
945 || text.contains("Running tool:")
946 || text.contains("Running:")
947 || text.contains("Running ")
948 || text.contains("Executing ")
949 || text.contains("Press Ctrl+C to cancel")
950 {
951 return true;
952 }
953 let Some((indicator, rest)) = text.split_once(' ') else {
954 return false;
955 };
956 if indicator.chars().count() != 1 || rest.trim().is_empty() {
957 return false;
958 }
959 is_spinner_frame(indicator)
960}
961
962#[derive(Clone, Debug)]
964pub struct InputWidgetData {
965 pub text: Text<'static>,
966 pub cursor_x: u16,
967 pub cursor_y: u16,
968 pub cursor_should_be_visible: bool,
969 pub use_fake_cursor: bool,
970 pub background_style: Style,
971 pub default_style: Style,
972}
973
974fn render_fake_cursor(buf: &mut Buffer, cursor_x: u16, cursor_y: u16) {
975 if let Some(cell) = buf.cell_mut((cursor_x, cursor_y)) {
976 let mut style = cell.style();
977 style = style.add_modifier(Modifier::REVERSED);
978 cell.set_style(style);
979 if cell.symbol().is_empty() {
980 cell.set_symbol(" ");
981 }
982 }
983}
984
985#[cfg(test)]
986mod input_highlight_tests {
987 use super::*;
988
989 fn kinds(input: &str) -> Vec<(InputTokenKind, String)> {
990 tokenize_input(input)
991 .into_iter()
992 .map(|t| {
993 let text: String = input.chars().skip(t.start).take(t.end - t.start).collect();
994 (t.kind, text)
995 })
996 .collect()
997 }
998
999 #[test]
1000 fn slash_command_at_start() {
1001 let tokens = kinds("/use skill-name");
1002 assert_eq!(tokens[0].0, InputTokenKind::SlashCommand);
1003 assert_eq!(tokens[0].1, "/use");
1004 assert_eq!(tokens[1].0, InputTokenKind::Normal);
1005 }
1006
1007 #[test]
1008 fn slash_command_with_following_text() {
1009 let tokens = kinds("/doctor hello");
1010 assert_eq!(tokens[0].0, InputTokenKind::SlashCommand);
1011 assert_eq!(tokens[0].1, "/doctor");
1012 assert_eq!(tokens[1].0, InputTokenKind::Normal);
1013 }
1014
1015 #[test]
1016 fn at_file_reference() {
1017 let tokens = kinds("check @src/main.rs please");
1018 assert_eq!(tokens[0].0, InputTokenKind::Normal);
1019 assert_eq!(tokens[1].0, InputTokenKind::FileReference);
1020 assert_eq!(tokens[1].1, "@src/main.rs");
1021 assert_eq!(tokens[2].0, InputTokenKind::Normal);
1022 }
1023
1024 #[test]
1025 fn inline_backtick_code() {
1026 let tokens = kinds("run `cargo test` now");
1027 assert_eq!(tokens[0].0, InputTokenKind::Normal);
1028 assert_eq!(tokens[1].0, InputTokenKind::InlineCode);
1029 assert_eq!(tokens[1].1, "`cargo test`");
1030 assert_eq!(tokens[2].0, InputTokenKind::Normal);
1031 }
1032
1033 #[test]
1034 fn no_false_slash_mid_word() {
1035 let tokens = kinds("path/to/file");
1036 assert_eq!(tokens.len(), 1);
1037 assert_eq!(tokens[0].0, InputTokenKind::Normal);
1038 }
1039
1040 #[test]
1041 fn empty_input() {
1042 assert!(tokenize_input("").is_empty());
1043 }
1044
1045 #[test]
1046 fn mixed_tokens() {
1047 let tokens = kinds("/use @file.rs `code`");
1048 assert_eq!(tokens[0].0, InputTokenKind::SlashCommand);
1049 assert_eq!(tokens[2].0, InputTokenKind::FileReference);
1050 assert_eq!(tokens[4].0, InputTokenKind::InlineCode);
1051 }
1052}