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(crate) 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 let background_style = self.styles.input_background_style();
217 let shell_mode_title = self.shell_mode_border_title();
218 let mut block = if shell_mode_title.is_some() {
219 Block::bordered()
220 } else {
221 Block::new()
222 };
223 block = block
224 .style(background_style)
225 .padding(self.input_block_padding());
226 if let Some(title) = shell_mode_title {
227 block = block
228 .title(title)
229 .border_type(super::terminal_capabilities::get_border_type())
230 .border_style(self.styles.accent_style().add_modifier(Modifier::BOLD));
231 }
232 let inner = block.inner(input_area);
233 self.set_input_area(Some(inner));
234 let input_render = self.build_input_render(inner.width, inner.height);
235 let paragraph = Paragraph::new(input_render.text)
236 .style(background_style)
237 .wrap(Wrap { trim: false });
238 frame.render_widget(paragraph.block(block), input_area);
239 self.apply_input_selection_highlight(frame.buffer_mut(), inner);
240 if self.input_manager.selection_needs_copy() {
241 let _ = self.input_manager.copy_selected_text_to_clipboard();
242 }
243
244 if self.cursor_should_be_visible() && inner.width > 0 && inner.height > 0 {
245 let cursor_x = input_render
246 .cursor_x
247 .min(inner.width.saturating_sub(1))
248 .saturating_add(inner.x);
249 let cursor_y = input_render
250 .cursor_y
251 .min(inner.height.saturating_sub(1))
252 .saturating_add(inner.y);
253 if self.use_fake_cursor() {
254 render_fake_cursor(frame.buffer_mut(), cursor_x, cursor_y);
255 } else {
256 frame.set_cursor_position(Position::new(cursor_x, cursor_y));
257 }
258 }
259
260 if let Some(status_area) = status_area {
261 let status_line = self
262 .render_input_status_line(status_area.width)
263 .unwrap_or_default();
264 let status = Paragraph::new(status_line)
265 .style(self.styles.default_style())
266 .wrap(Wrap { trim: false });
267 frame.render_widget(status, status_area);
268 }
269 }
270
271 pub(crate) fn desired_input_lines(&self, inner_width: u16) -> u16 {
272 if inner_width == 0 {
273 return 1;
274 }
275
276 if self.input_compact_mode
277 && self.input_manager.cursor() == self.input_manager.content().len()
278 && self.input_compact_placeholder().is_some()
279 {
280 return 1;
281 }
282
283 if self.input_manager.content().is_empty() {
284 return 1;
285 }
286
287 let prompt_width = UnicodeWidthStr::width(self.prompt_prefix.as_str()) as u16;
288 let prompt_display_width = prompt_width.min(inner_width);
289 let layout = self.input_layout(inner_width, prompt_display_width);
290 let line_count = layout.buffers.len().max(1);
291 let capped = line_count.min(ui::INLINE_INPUT_MAX_LINES.max(1));
292 capped as u16
293 }
294
295 pub(crate) fn apply_input_height(&mut self, height: u16) {
296 let resolved = height.max(Self::input_block_height_for_lines(1));
297 if self.input_height != resolved {
298 self.input_height = resolved;
299 self.recalculate_transcript_rows();
300 }
301 }
302
303 pub(crate) fn input_block_height_for_lines(lines: u16) -> u16 {
304 lines
305 .max(1)
306 .saturating_add(ui::INLINE_INPUT_PADDING_VERTICAL.saturating_mul(2))
307 }
308
309 fn input_layout(&self, width: u16, prompt_display_width: u16) -> InputLayout {
310 let indent_prefix = " ".repeat(prompt_display_width as usize);
311 let mut buffers = vec![InputLineBuffer::new(
312 self.prompt_prefix.clone(),
313 prompt_display_width,
314 0,
315 )];
316 let secure_prompt_active = self.secure_prompt_active();
317 let mut cursor_line_idx = 0usize;
318 let mut cursor_column = prompt_display_width;
319 let input_content = self.input_manager.content();
320 let cursor_pos = self.input_manager.cursor();
321 let mut cursor_set = cursor_pos == 0;
322 let mut char_idx: usize = 0;
323
324 for (idx, ch) in input_content.char_indices() {
325 if !cursor_set
326 && cursor_pos == idx
327 && let Some(current) = buffers.last()
328 {
329 cursor_line_idx = buffers.len() - 1;
330 cursor_column = current.prefix_width + current.text_width;
331 cursor_set = true;
332 }
333
334 if ch == '\n' {
335 let end = idx + ch.len_utf8();
336 char_idx += 1;
337 buffers.push(InputLineBuffer::new(
338 indent_prefix.clone(),
339 prompt_display_width,
340 char_idx,
341 ));
342 if !cursor_set && cursor_pos == end {
343 cursor_line_idx = buffers.len() - 1;
344 cursor_column = prompt_display_width;
345 cursor_set = true;
346 }
347 continue;
348 }
349
350 let display_ch = if secure_prompt_active { '•' } else { ch };
351 let char_width = UnicodeWidthChar::width(display_ch).unwrap_or(0) as u16;
352
353 if let Some(current) = buffers.last_mut() {
354 let capacity = width.saturating_sub(current.prefix_width);
355 if capacity > 0
356 && current.text_width + char_width > capacity
357 && !current.text.is_empty()
358 {
359 buffers.push(InputLineBuffer::new(
360 indent_prefix.clone(),
361 prompt_display_width,
362 char_idx,
363 ));
364 }
365 }
366
367 if let Some(current) = buffers.last_mut() {
368 current.text.push(display_ch);
369 current.text_width = current.text_width.saturating_add(char_width);
370 }
371
372 char_idx += 1;
373
374 let end = idx + ch.len_utf8();
375 if !cursor_set
376 && cursor_pos == end
377 && let Some(current) = buffers.last()
378 {
379 cursor_line_idx = buffers.len() - 1;
380 cursor_column = current.prefix_width + current.text_width;
381 cursor_set = true;
382 }
383 }
384
385 if !cursor_set && let Some(current) = buffers.last() {
386 cursor_line_idx = buffers.len() - 1;
387 cursor_column = current.prefix_width + current.text_width;
388 }
389
390 InputLayout {
391 buffers,
392 cursor_line_idx,
393 cursor_column,
394 }
395 }
396
397 fn visible_input_window(&self, width: u16, height: u16) -> (InputLayout, usize, usize) {
398 let prompt_width = UnicodeWidthStr::width(self.prompt_prefix.as_str()) as u16;
399 let prompt_display_width = prompt_width.min(width);
400 let layout = self.input_layout(width, prompt_display_width);
401 let total_lines = layout.buffers.len();
402 let visible_limit = height.max(1).min(ui::INLINE_INPUT_MAX_LINES as u16) as usize;
403 let mut start = total_lines.saturating_sub(visible_limit);
404 if layout.cursor_line_idx < start {
405 start = layout.cursor_line_idx.saturating_sub(visible_limit - 1);
406 }
407 let end = (start + visible_limit).min(total_lines);
408 (layout, start, end)
409 }
410
411 fn build_input_render(&self, width: u16, height: u16) -> InputRender {
412 if width == 0 || height == 0 {
413 return InputRender {
414 text: Text::default(),
415 cursor_x: 0,
416 cursor_y: 0,
417 };
418 }
419
420 let max_visible_lines = height.max(1).min(ui::INLINE_INPUT_MAX_LINES as u16) as usize;
421
422 let mut prompt_style = self.prompt_style.clone();
423 if prompt_style.color.is_none() {
424 prompt_style.color = self.theme.primary.or(self.theme.foreground);
425 }
426 if self.suggested_prompt_state.active {
427 prompt_style.color = self
428 .theme
429 .tool_accent
430 .or(self.theme.secondary)
431 .or(self.theme.primary)
432 .or(self.theme.foreground);
433 prompt_style.effects |= Effects::BOLD;
434 }
435 let prompt_style = ratatui_style_from_inline(&prompt_style, self.theme.foreground);
436 let prompt_width = UnicodeWidthStr::width(self.prompt_prefix.as_str()) as u16;
437 let prompt_display_width = prompt_width.min(width);
438
439 let cursor_at_end = self.input_manager.cursor() == self.input_manager.content().len();
440 if self.input_compact_mode
441 && cursor_at_end
442 && let Some(placeholder) = self.input_compact_placeholder()
443 {
444 let placeholder_style = InlineTextStyle {
445 color: Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
446 bg_color: None,
447 effects: Effects::DIMMED,
448 };
449 let style = ratatui_style_from_inline(
450 &placeholder_style,
451 Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
452 );
453 let placeholder_width = UnicodeWidthStr::width(placeholder.as_str()) as u16;
454 return InputRender {
455 text: Text::from(vec![Line::from(vec![
456 Span::styled(self.prompt_prefix.clone(), prompt_style),
457 Span::styled(placeholder, style),
458 ])]),
459 cursor_x: prompt_display_width.saturating_add(placeholder_width),
460 cursor_y: 0,
461 };
462 }
463
464 if self.input_manager.content().is_empty() {
465 let mut spans = Vec::new();
466 spans.push(Span::styled(self.prompt_prefix.clone(), prompt_style));
467
468 if let Some(suffix) = self.visible_inline_prompt_suggestion_suffix() {
469 let ghost_style = ratatui_style_from_inline(
470 &InlineTextStyle {
471 color: Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
472 bg_color: None,
473 effects: Effects::DIMMED | Effects::ITALIC,
474 },
475 Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
476 );
477 spans.push(Span::styled(suffix, ghost_style));
478 } else if let Some(placeholder) = &self.placeholder {
479 let placeholder_style = self.placeholder_style.clone().unwrap_or(InlineTextStyle {
480 color: Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
481 bg_color: None,
482 effects: Effects::ITALIC,
483 });
484 let style = ratatui_style_from_inline(
485 &placeholder_style,
486 Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
487 );
488 spans.push(Span::styled(placeholder.clone(), style));
489 }
490
491 return InputRender {
492 text: Text::from(vec![Line::from(spans)]),
493 cursor_x: prompt_display_width,
494 cursor_y: 0,
495 };
496 }
497
498 let accent_style =
499 ratatui_style_from_inline(&self.styles.accent_inline_style(), self.theme.foreground);
500 let slash_style = accent_style.fg(Color::Yellow).add_modifier(Modifier::BOLD);
501 let file_ref_style = accent_style
502 .fg(Color::Cyan)
503 .add_modifier(Modifier::UNDERLINED);
504 let code_style = accent_style.fg(Color::Green).add_modifier(Modifier::BOLD);
505
506 let (layout, start, end) = self.visible_input_window(width, max_visible_lines as u16);
507 let tokens = tokenize_input(self.input_manager.content());
508 let cursor_y = layout.cursor_line_idx.saturating_sub(start) as u16;
509
510 let mut lines = Vec::new();
511 for buffer in &layout.buffers[start..end] {
512 let mut spans = Vec::new();
513 spans.push(Span::styled(buffer.prefix.clone(), prompt_style));
514 if !buffer.text.is_empty() {
515 let buf_chars: Vec<char> = buffer.text.chars().collect();
516 let buf_len = buf_chars.len();
517 let buf_start = buffer.char_start;
518 let buf_end = buf_start + buf_len;
519
520 let mut pos = 0usize;
521 for token in &tokens {
522 if token.end <= buf_start || token.start >= buf_end {
523 continue;
524 }
525 let seg_start = token.start.max(buf_start).saturating_sub(buf_start);
526 let seg_end = token.end.min(buf_end).saturating_sub(buf_start);
527 if seg_start > pos {
528 let text: String = buf_chars[pos..seg_start].iter().collect();
529 spans.push(Span::styled(text, accent_style));
530 }
531 let text: String = buf_chars[seg_start..seg_end].iter().collect();
532 let style = match token.kind {
533 InputTokenKind::SlashCommand => slash_style,
534 InputTokenKind::FileReference => file_ref_style,
535 InputTokenKind::InlineCode => code_style,
536 InputTokenKind::Normal => accent_style,
537 };
538 spans.push(Span::styled(text, style));
539 pos = seg_end;
540 }
541 if pos < buf_len {
542 let text: String = buf_chars[pos..].iter().collect();
543 spans.push(Span::styled(text, accent_style));
544 }
545 }
546 lines.push(Line::from(spans));
547 }
548
549 if let Some(suffix) = self.visible_inline_prompt_suggestion_suffix() {
550 let ghost_style = ratatui_style_from_inline(
551 &InlineTextStyle {
552 color: Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
553 bg_color: None,
554 effects: Effects::DIMMED | Effects::ITALIC,
555 },
556 Some(AnsiColorEnum::Rgb(PLACEHOLDER_COLOR)),
557 );
558 if let Some(line) = lines.get_mut(cursor_y as usize) {
559 line.spans.push(Span::styled(suffix, ghost_style));
560 }
561 }
562
563 if lines.is_empty() {
564 lines.push(Line::from(vec![Span::styled(
565 self.prompt_prefix.clone(),
566 prompt_style,
567 )]));
568 }
569
570 InputRender {
571 text: Text::from(lines),
572 cursor_x: layout.cursor_column,
573 cursor_y,
574 }
575 }
576
577 fn apply_input_selection_highlight(&self, buf: &mut Buffer, area: Rect) {
578 let Some((selection_start, selection_end)) = self.input_manager.selection_range() else {
579 return;
580 };
581 if area.width == 0 || area.height == 0 || selection_start == selection_end {
582 return;
583 }
584
585 let (layout, start, end) = self.visible_input_window(area.width, area.height);
586 let selection_start_char =
587 byte_index_to_char_index(self.input_manager.content(), selection_start);
588 let selection_end_char =
589 byte_index_to_char_index(self.input_manager.content(), selection_end);
590
591 for (row_offset, buffer) in layout.buffers[start..end].iter().enumerate() {
592 let line_char_start = buffer.char_start;
593 let line_char_end = buffer.char_start + buffer.text.chars().count();
594 let highlight_start = selection_start_char.max(line_char_start);
595 let highlight_end = selection_end_char.min(line_char_end);
596 if highlight_start >= highlight_end {
597 continue;
598 }
599
600 let local_start = highlight_start.saturating_sub(line_char_start);
601 let local_end = highlight_end.saturating_sub(line_char_start);
602 let start_x = area
603 .x
604 .saturating_add(buffer.prefix_width)
605 .saturating_add(display_width_for_char_range(&buffer.text, local_start));
606 let end_x = area
607 .x
608 .saturating_add(buffer.prefix_width)
609 .saturating_add(display_width_for_char_range(&buffer.text, local_end));
610 let y = area.y.saturating_add(row_offset as u16);
611
612 for x in start_x..end_x.min(area.x.saturating_add(area.width)) {
613 if let Some(cell) = buf.cell_mut((x, y)) {
614 let mut style = cell.style();
615 style = style.add_modifier(Modifier::REVERSED);
616 cell.set_style(style);
617 if cell.symbol().is_empty() {
618 cell.set_symbol(" ");
619 }
620 }
621 }
622 }
623 }
624
625 pub(crate) fn cursor_index_for_input_point(&self, column: u16, row: u16) -> Option<usize> {
626 let area = self.input_area?;
627 if row < area.y
628 || row >= area.y.saturating_add(area.height)
629 || column < area.x
630 || column >= area.x.saturating_add(area.width)
631 {
632 return None;
633 }
634
635 if self.input_compact_mode
636 && self.input_manager.cursor() == self.input_manager.content().len()
637 && self.input_compact_placeholder().is_some()
638 {
639 return Some(self.input_manager.content().len());
640 }
641
642 let relative_row = row.saturating_sub(area.y);
643 let relative_column = column.saturating_sub(area.x);
644 let (layout, start, end) = self.visible_input_window(area.width, area.height);
645 if start >= end {
646 return Some(0);
647 }
648
649 let line_index = (start + usize::from(relative_row)).min(end.saturating_sub(1));
650 let buffer = layout.buffers.get(line_index)?;
651 if relative_column <= buffer.prefix_width {
652 return Some(char_index_to_byte_index(
653 self.input_manager.content(),
654 buffer.char_start,
655 ));
656 }
657
658 let target_width = relative_column.saturating_sub(buffer.prefix_width);
659 let mut consumed_width = 0u16;
660 let mut char_offset = 0usize;
661 for ch in buffer.text.chars() {
662 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
663 let next_width = consumed_width.saturating_add(ch_width);
664 if target_width < next_width {
665 break;
666 }
667 consumed_width = next_width;
668 char_offset += 1;
669 }
670
671 let char_index = buffer.char_start.saturating_add(char_offset);
672 Some(char_index_to_byte_index(
673 self.input_manager.content(),
674 char_index,
675 ))
676 }
677
678 pub(crate) fn input_compact_placeholder(&self) -> Option<String> {
679 let content = self.input_manager.content();
680 let trimmed = content.trim();
681 let attachment_count = self.input_manager.attachments().len();
682 if trimmed.is_empty() && attachment_count == 0 {
683 return None;
684 }
685
686 if let Some(label) = compact_image_label(trimmed) {
687 return Some(format!("[Image: {label}]"));
688 }
689
690 if attachment_count > 0 {
691 let label = if attachment_count == 1 {
692 "1 attachment".to_string()
693 } else {
694 format!("{attachment_count} attachments")
695 };
696 if trimmed.is_empty() {
697 return Some(format!("[Image: {label}]"));
698 }
699 if let Some(compact) = compact_image_placeholders(content) {
700 return Some(format!("[Image: {label}] {compact}"));
701 }
702 return Some(format!("[Image: {label}] {trimmed}"));
703 }
704
705 let line_count = content.split('\n').count();
706 if line_count >= ui::INLINE_PASTE_COLLAPSE_LINE_THRESHOLD {
707 let char_count = content.chars().count();
708 return Some(format!("[Pasted Content {char_count} chars]"));
709 }
710
711 if let Some(compact) = compact_image_placeholders(content) {
712 return Some(compact);
713 }
714
715 None
716 }
717
718 pub(crate) fn visible_inline_prompt_suggestion_suffix(&self) -> Option<String> {
719 if !self.input_enabled
720 || self.has_active_overlay()
721 || self.input_compact_mode
722 || self.input_manager.cursor() != self.input_manager.content().len()
723 {
724 return None;
725 }
726
727 let suggestion = self.inline_prompt_suggestion.suggestion.as_deref()?;
728 inline_prompt_suggestion_suffix(self.input_manager.content(), suggestion)
729 }
730
731 fn render_input_status_line(&self, width: u16) -> Option<Line<'static>> {
732 if width == 0 {
733 return None;
734 }
735
736 let mut left = self
737 .input_status_left
738 .as_ref()
739 .map(|value| value.trim().to_owned())
740 .filter(|value| !value.is_empty());
741 let right = self
742 .input_status_right
743 .as_ref()
744 .map(|value| value.trim().to_owned())
745 .filter(|value| !value.is_empty());
746
747 if let Some(shell_hint) = self.shell_mode_status_hint() {
748 left = Some(match left {
749 Some(existing) => format!("{existing} · {shell_hint}"),
750 None => shell_hint.to_string(),
751 });
752 }
753
754 let right = match (right, self.vim_state.status_label()) {
755 (Some(existing), Some(vim_label)) => Some(format!("{vim_label} · {existing}")),
756 (None, Some(vim_label)) => Some(vim_label.to_string()),
757 (existing, None) => existing,
758 };
759
760 let scroll_indicator = if ui::SCROLL_INDICATOR_ENABLED {
762 Some(self.build_scroll_indicator())
763 } else {
764 None
765 };
766
767 if left.is_none() && right.is_none() && scroll_indicator.is_none() {
768 return None;
769 }
770
771 let dim_style = self.styles.default_style().add_modifier(Modifier::DIM);
772 let mut spans = Vec::new();
773
774 if let Some(left_value) = left.as_ref() {
776 if status_requires_shimmer(left_value)
777 && self.appearance.should_animate_progress_status()
778 {
779 spans.extend(shimmer_spans_with_style_at_phase(
780 left_value,
781 self.styles.accent_style().add_modifier(Modifier::DIM),
782 self.shimmer_state.phase(),
783 ));
784 } else {
785 spans.extend(self.create_git_status_spans(left_value, dim_style));
786 }
787 }
788
789 let mut right_spans: Vec<Span<'static>> = Vec::new();
791 if let Some(scroll) = &scroll_indicator {
792 right_spans.push(Span::styled(scroll.clone(), dim_style));
793 }
794 if let Some(right_value) = &right {
795 if !right_spans.is_empty() {
796 right_spans.push(Span::raw(" "));
797 }
798 right_spans.push(Span::styled(right_value.clone(), dim_style));
799 }
800
801 if !right_spans.is_empty() {
802 let left_width: u16 = spans.iter().map(|s| measure_text_width(&s.content)).sum();
803 let right_width: u16 = right_spans
804 .iter()
805 .map(|s| measure_text_width(&s.content))
806 .sum();
807 let padding = width.saturating_sub(left_width + right_width);
808
809 if padding > 0 {
810 spans.push(Span::raw(" ".repeat(padding as usize)));
811 } else if !spans.is_empty() {
812 spans.push(Span::raw(" "));
813 }
814 spans.extend(right_spans);
815 }
816
817 if spans.is_empty() {
818 return None;
819 }
820
821 let mut line = Line::from(spans);
822 line = truncate_line_with_ellipsis_if_overflow(line, usize::from(width));
824 Some(line)
825 }
826
827 pub(crate) fn input_uses_shell_prefix(&self) -> bool {
828 self.input_manager.content().trim_start().starts_with('!')
829 }
830
831 pub(crate) fn input_block_padding(&self) -> Padding {
832 if self.input_uses_shell_prefix() {
833 Padding::new(0, 0, 0, 0)
834 } else {
835 Padding::new(
836 ui::INLINE_INPUT_PADDING_HORIZONTAL,
837 ui::INLINE_INPUT_PADDING_HORIZONTAL,
838 ui::INLINE_INPUT_PADDING_VERTICAL,
839 ui::INLINE_INPUT_PADDING_VERTICAL,
840 )
841 }
842 }
843
844 pub(crate) fn shell_mode_border_title(&self) -> Option<&'static str> {
845 self.input_uses_shell_prefix()
846 .then_some(SHELL_MODE_BORDER_TITLE)
847 }
848
849 fn shell_mode_status_hint(&self) -> Option<&'static str> {
850 self.input_uses_shell_prefix()
851 .then_some(SHELL_MODE_STATUS_HINT)
852 }
853
854 fn build_scroll_indicator(&self) -> String {
856 let percent = self.scroll_manager.progress_percent();
857 format!("{} {:>3}%", ui::SCROLL_INDICATOR_FORMAT, percent)
858 }
859
860 #[allow(dead_code)]
861 fn create_git_status_spans(&self, text: &str, default_style: Style) -> Vec<Span<'static>> {
862 if let Some((branch_part, indicator_part)) = text.rsplit_once(" | ") {
863 let mut spans = Vec::new();
864 let branch_trim = branch_part.trim_end();
865 if !branch_trim.is_empty() {
866 spans.push(Span::styled(branch_trim.to_owned(), default_style));
867 }
868 spans.push(Span::raw(" "));
869
870 let indicator_trim = indicator_part.trim();
871 let indicator_style = if indicator_trim == ui::HEADER_GIT_DIRTY_SUFFIX {
872 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
873 } else if indicator_trim == ui::HEADER_GIT_CLEAN_SUFFIX {
874 Style::default()
875 .fg(Color::Green)
876 .add_modifier(Modifier::BOLD)
877 } else {
878 self.styles.accent_style().add_modifier(Modifier::BOLD)
879 };
880
881 spans.push(Span::styled(indicator_trim.to_owned(), indicator_style));
882 spans
883 } else {
884 vec![Span::styled(text.to_owned(), default_style)]
885 }
886 }
887
888 fn cursor_should_be_visible(&self) -> bool {
889 let loading_state = self.is_running_activity() || self.has_status_spinner();
890 self.cursor_visible && (self.input_enabled || loading_state)
891 }
892
893 fn use_fake_cursor(&self) -> bool {
894 self.has_status_spinner()
895 }
896
897 fn secure_prompt_active(&self) -> bool {
898 self.modal_state()
899 .and_then(|modal| modal.secure_prompt.as_ref())
900 .is_some()
901 }
902
903 pub fn build_input_widget_data(&self, width: u16, height: u16) -> InputWidgetData {
905 let input_render = self.build_input_render(width, height);
906 let background_style = self.styles.input_background_style();
907
908 InputWidgetData {
909 text: input_render.text,
910 cursor_x: input_render.cursor_x,
911 cursor_y: input_render.cursor_y,
912 cursor_should_be_visible: self.cursor_should_be_visible(),
913 use_fake_cursor: self.use_fake_cursor(),
914 background_style,
915 default_style: self.styles.default_style(),
916 }
917 }
918
919 pub fn build_input_status_widget_data(&self, width: u16) -> Option<Vec<Span<'static>>> {
921 self.render_input_status_line(width).map(|line| line.spans)
922 }
923}
924
925fn inline_prompt_suggestion_suffix(current: &str, suggestion: &str) -> Option<String> {
926 if current.trim().is_empty() {
927 return Some(suggestion.to_string());
928 }
929
930 let suggestion_lower = suggestion.to_lowercase();
931 let current_lower = current.to_lowercase();
932 if !suggestion_lower.starts_with(¤t_lower) {
933 return None;
934 }
935
936 Some(suggestion.chars().skip(current.chars().count()).collect())
937}
938
939fn compact_image_label(content: &str) -> Option<String> {
940 let trimmed = content.trim();
941 if trimmed.is_empty() {
942 return None;
943 }
944
945 let unquoted = trimmed
946 .strip_prefix('"')
947 .and_then(|value| value.strip_suffix('"'))
948 .or_else(|| {
949 trimmed
950 .strip_prefix('\'')
951 .and_then(|value| value.strip_suffix('\''))
952 })
953 .unwrap_or(trimmed);
954
955 if unquoted.starts_with("data:image/") {
956 return Some("inline image".to_string());
957 }
958
959 let windows_drive = unquoted.as_bytes().get(1).is_some_and(|ch| *ch == b':')
960 && unquoted
961 .as_bytes()
962 .get(2)
963 .is_some_and(|ch| *ch == b'\\' || *ch == b'/');
964 let starts_like_path = unquoted.starts_with('@')
965 || unquoted.starts_with("file://")
966 || unquoted.starts_with('/')
967 || unquoted.starts_with("./")
968 || unquoted.starts_with("../")
969 || unquoted.starts_with("~/")
970 || windows_drive;
971 if !starts_like_path {
972 return None;
973 }
974
975 let without_at = unquoted.strip_prefix('@').unwrap_or(unquoted);
976
977 if without_at.contains('/')
979 && !without_at.starts_with('.')
980 && !without_at.starts_with('/')
981 && !without_at.starts_with("~/")
982 {
983 let parts: Vec<&str> = without_at.split('/').collect();
985 if parts.len() >= 2 && !parts[0].is_empty() {
986 if !parts[parts.len() - 1].contains('.') {
988 return None;
989 }
990 }
991 }
992
993 let without_scheme = without_at.strip_prefix("file://").unwrap_or(without_at);
994 let path = Path::new(without_scheme);
995 if !is_image_path(path) {
996 return None;
997 }
998
999 let label = path
1000 .file_name()
1001 .and_then(|name| name.to_str())
1002 .unwrap_or(without_scheme);
1003 Some(label.to_string())
1004}
1005
1006static IMAGE_PATH_INLINE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
1007 match Regex::new(
1008 r#"(?ix)
1009 (?:^|[\s\(\[\{<\"'`])
1010 (
1011 @?
1012 (?:file://)?
1013 (?:
1014 ~/(?:[^\n/]+/)+
1015 | /(?:[^\n/]+/)+
1016 | [A-Za-z]:[\\/](?:[^\n\\\/]+[\\/])+
1017 )
1018 [^\n]*?
1019 \.(?:png|jpe?g|gif|bmp|webp|tiff?|svg)
1020 )"#,
1021 ) {
1022 Ok(regex) => regex,
1023 Err(error) => panic!("Failed to compile inline image path regex: {error}"),
1024 }
1025});
1026
1027fn compact_image_placeholders(content: &str) -> Option<String> {
1028 let mut matches = Vec::new();
1029 for capture in IMAGE_PATH_INLINE_REGEX.captures_iter(content) {
1030 let Some(path_match) = capture.get(1) else {
1031 continue;
1032 };
1033 let raw = path_match.as_str();
1034 let Some(label) = image_label_for_path(raw) else {
1035 continue;
1036 };
1037 matches.push((path_match.start(), path_match.end(), label));
1038 }
1039
1040 if matches.is_empty() {
1041 return None;
1042 }
1043
1044 let mut result = String::with_capacity(content.len());
1045 let mut last_end = 0usize;
1046 for (start, end, label) in matches {
1047 if start < last_end {
1048 continue;
1049 }
1050 result.push_str(&content[last_end..start]);
1051 result.push_str(&format!("[Image: {label}]"));
1052 last_end = end;
1053 }
1054 if last_end < content.len() {
1055 result.push_str(&content[last_end..]);
1056 }
1057
1058 Some(result)
1059}
1060
1061fn image_label_for_path(raw: &str) -> Option<String> {
1062 let trimmed = raw.trim_matches(|ch: char| matches!(ch, '"' | '\'')).trim();
1063 if trimmed.is_empty() {
1064 return None;
1065 }
1066
1067 let without_at = trimmed.strip_prefix('@').unwrap_or(trimmed);
1068 let without_scheme = without_at.strip_prefix("file://").unwrap_or(without_at);
1069 let unescaped = unescape_whitespace(without_scheme);
1070 let path = Path::new(unescaped.as_str());
1071 if !is_image_path(path) {
1072 return None;
1073 }
1074
1075 let label = path
1076 .file_name()
1077 .and_then(|name| name.to_str())
1078 .unwrap_or(unescaped.as_str());
1079 Some(label.to_string())
1080}
1081
1082fn unescape_whitespace(token: &str) -> String {
1083 let mut result = String::with_capacity(token.len());
1084 let mut chars = token.chars().peekable();
1085 while let Some(ch) = chars.next() {
1086 if ch == '\\'
1087 && let Some(next) = chars.peek()
1088 && next.is_ascii_whitespace()
1089 {
1090 result.push(*next);
1091 chars.next();
1092 continue;
1093 }
1094 result.push(ch);
1095 }
1096 result
1097}
1098
1099fn is_spinner_frame(indicator: &str) -> bool {
1100 matches!(
1101 indicator,
1102 "⠋" | "⠙"
1103 | "⠹"
1104 | "⠸"
1105 | "⠼"
1106 | "⠴"
1107 | "⠦"
1108 | "⠧"
1109 | "⠇"
1110 | "⠏"
1111 | "-"
1112 | "\\"
1113 | "|"
1114 | "/"
1115 | "."
1116 )
1117}
1118
1119pub(crate) fn status_requires_shimmer(text: &str) -> bool {
1120 if text.contains("Running command:")
1121 || text.contains("Running tool:")
1122 || text.contains("Running:")
1123 || text.contains("Running ")
1124 || text.contains("Executing ")
1125 || text.contains("Ctrl+C")
1126 || text.contains("/stop to stop")
1127 {
1128 return true;
1129 }
1130 let Some((indicator, rest)) = text.split_once(' ') else {
1131 return false;
1132 };
1133 if indicator.chars().count() != 1 || rest.trim().is_empty() {
1134 return false;
1135 }
1136 is_spinner_frame(indicator)
1137}
1138
1139#[derive(Clone, Debug)]
1141pub struct InputWidgetData {
1142 pub text: Text<'static>,
1143 pub cursor_x: u16,
1144 pub cursor_y: u16,
1145 pub cursor_should_be_visible: bool,
1146 pub use_fake_cursor: bool,
1147 pub background_style: Style,
1148 pub default_style: Style,
1149}
1150
1151fn render_fake_cursor(buf: &mut Buffer, cursor_x: u16, cursor_y: u16) {
1152 if let Some(cell) = buf.cell_mut((cursor_x, cursor_y)) {
1153 let mut style = cell.style();
1154 style = style.add_modifier(Modifier::REVERSED);
1155 cell.set_style(style);
1156 if cell.symbol().is_empty() {
1157 cell.set_symbol(" ");
1158 }
1159 }
1160}
1161
1162fn char_index_to_byte_index(content: &str, char_index: usize) -> usize {
1163 if char_index == 0 {
1164 return 0;
1165 }
1166
1167 content
1168 .char_indices()
1169 .nth(char_index)
1170 .map(|(byte_index, _)| byte_index)
1171 .unwrap_or(content.len())
1172}
1173
1174fn byte_index_to_char_index(content: &str, byte_index: usize) -> usize {
1175 content[..byte_index.min(content.len())].chars().count()
1176}
1177
1178fn display_width_for_char_range(content: &str, char_count: usize) -> u16 {
1179 content
1180 .chars()
1181 .take(char_count)
1182 .map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0) as u16)
1183 .fold(0_u16, u16::saturating_add)
1184}
1185
1186#[cfg(test)]
1187mod input_highlight_tests {
1188 use super::*;
1189
1190 fn kinds(input: &str) -> Vec<(InputTokenKind, String)> {
1191 tokenize_input(input)
1192 .into_iter()
1193 .map(|t| {
1194 let text: String = input.chars().skip(t.start).take(t.end - t.start).collect();
1195 (t.kind, text)
1196 })
1197 .collect()
1198 }
1199
1200 #[test]
1201 fn slash_command_at_start() {
1202 let tokens = kinds("/use skill-name");
1203 assert_eq!(tokens[0].0, InputTokenKind::SlashCommand);
1204 assert_eq!(tokens[0].1, "/use");
1205 assert_eq!(tokens[1].0, InputTokenKind::Normal);
1206 }
1207
1208 #[test]
1209 fn slash_command_with_following_text() {
1210 let tokens = kinds("/doctor hello");
1211 assert_eq!(tokens[0].0, InputTokenKind::SlashCommand);
1212 assert_eq!(tokens[0].1, "/doctor");
1213 assert_eq!(tokens[1].0, InputTokenKind::Normal);
1214 }
1215
1216 #[test]
1217 fn at_file_reference() {
1218 let tokens = kinds("check @src/main.rs please");
1219 assert_eq!(tokens[0].0, InputTokenKind::Normal);
1220 assert_eq!(tokens[1].0, InputTokenKind::FileReference);
1221 assert_eq!(tokens[1].1, "@src/main.rs");
1222 assert_eq!(tokens[2].0, InputTokenKind::Normal);
1223 }
1224
1225 #[test]
1226 fn inline_backtick_code() {
1227 let tokens = kinds("run `cargo test` now");
1228 assert_eq!(tokens[0].0, InputTokenKind::Normal);
1229 assert_eq!(tokens[1].0, InputTokenKind::InlineCode);
1230 assert_eq!(tokens[1].1, "`cargo test`");
1231 assert_eq!(tokens[2].0, InputTokenKind::Normal);
1232 }
1233
1234 #[test]
1235 fn no_false_slash_mid_word() {
1236 let tokens = kinds("path/to/file");
1237 assert_eq!(tokens.len(), 1);
1238 assert_eq!(tokens[0].0, InputTokenKind::Normal);
1239 }
1240
1241 #[test]
1242 fn empty_input() {
1243 assert!(tokenize_input("").is_empty());
1244 }
1245
1246 #[test]
1247 fn mixed_tokens() {
1248 let tokens = kinds("/use @file.rs `code`");
1249 assert_eq!(tokens[0].0, InputTokenKind::SlashCommand);
1250 assert_eq!(tokens[2].0, InputTokenKind::FileReference);
1251 assert_eq!(tokens[4].0, InputTokenKind::InlineCode);
1252 }
1253}